Repository: alicevision/Meshroom Branch: develop Commit: 796e610a8654 Files: 343 Total size: 2.4 MB Directory structure: gitextract_yg7v1w9_/ ├── .codecov.yml ├── .git-blame-ignore-revs ├── .github/ │ ├── FUNDING.yml │ ├── ISSUE_TEMPLATE/ │ │ ├── bug_report.md │ │ ├── feature_request.md │ │ └── question_help.md │ ├── pull_request_template.md │ ├── stale.yml │ └── workflows/ │ └── continuous-integration.yml ├── .gitignore ├── .readthedocs.yaml ├── CHANGES.md ├── CMakeLists.txt ├── CODE_OF_CONDUCT.md ├── CONTRIBUTING.md ├── COPYING.md ├── INSTALL.md ├── INSTALL_PLUGINS.md ├── LICENSE-MPL2.md ├── NODE_DEVELOPMENT.md ├── README.md ├── RELEASING.md ├── WINDOWS_EXE.md ├── bin/ │ ├── meshroom_batch │ ├── meshroom_compute │ ├── meshroom_createChunks │ ├── meshroom_newNodeType │ ├── meshroom_statistics │ ├── meshroom_status │ └── meshroom_submit ├── dev_requirements.txt ├── docker/ │ ├── Dockerfile_rocky │ ├── Dockerfile_rocky_deps │ ├── Dockerfile_ubuntu │ ├── Dockerfile_ubuntu_deps │ ├── build-all.sh │ ├── build-rocky.sh │ ├── build-ubuntu.sh │ ├── extract-rocky.sh │ └── extract-ubuntu.sh ├── docs/ │ ├── .gitignore │ ├── Makefile │ ├── README.md │ ├── make.bat │ ├── requirements.txt │ └── source/ │ ├── _ext/ │ │ ├── __init__.py │ │ ├── fetch_md.py │ │ ├── meshroom_doc.py │ │ └── utils.py │ ├── _templates/ │ │ └── autosummary/ │ │ ├── class.rst │ │ └── module.rst │ ├── api.rst │ ├── changes.rst │ ├── conf.py │ ├── index.rst │ └── install.rst ├── localfarm/ │ ├── __init__.py │ ├── localFarm.py │ ├── localFarmBackend.py │ ├── localFarmLauncher.py │ └── test.py ├── meshroom/ │ ├── __init__.py │ ├── common/ │ │ ├── PySignal.py │ │ ├── __init__.py │ │ ├── core.py │ │ ├── deprecated.py │ │ └── qt.py │ ├── core/ │ │ ├── __init__.py │ │ ├── attribute.py │ │ ├── cgroup.py │ │ ├── desc/ │ │ │ ├── __init__.py │ │ │ ├── attribute.py │ │ │ ├── computation.py │ │ │ ├── geometryAttribute.py │ │ │ ├── node.py │ │ │ └── shapeAttribute.py │ │ ├── evaluation.py │ │ ├── exception.py │ │ ├── fileUtils.py │ │ ├── graph.py │ │ ├── graphIO.py │ │ ├── keyValues.py │ │ ├── mtyping.py │ │ ├── node.py │ │ ├── nodeFactory.py │ │ ├── plugins.py │ │ ├── stats.py │ │ ├── submitter.py │ │ ├── taskManager.py │ │ ├── test.py │ │ └── utils.py │ ├── env.py │ ├── multiview.py │ ├── nodes/ │ │ ├── __init__.py │ │ └── general/ │ │ ├── Backdrop.py │ │ ├── CopyFiles.py │ │ ├── InputFile.py │ │ └── __init__.py │ ├── submitters/ │ │ ├── __init__.py │ │ └── localFarmSubmitter.py │ └── ui/ │ ├── __init__.py │ ├── __main__.py │ ├── app.py │ ├── commands.py │ ├── components/ │ │ ├── __init__.py │ │ ├── clipboard.py │ │ ├── csvData.py │ │ ├── edge.py │ │ ├── filepath.py │ │ ├── geom2D.py │ │ ├── logLinesModel.py │ │ ├── messaging.py │ │ ├── scene3D.py │ │ ├── scriptEditor.py │ │ ├── shapes/ │ │ │ ├── __init__.py │ │ │ ├── shapeFile.py │ │ │ ├── shapeFilesHelper.py │ │ │ └── shapeViewerHelper.py │ │ └── thumbnail.py │ ├── graph.py │ ├── palette.py │ ├── qml/ │ │ ├── AboutDialog.qml │ │ ├── Application.qml │ │ ├── Charts/ │ │ │ ├── ChartViewCheckBox.qml │ │ │ ├── ChartViewLegend.qml │ │ │ ├── InteractiveChartView.qml │ │ │ └── qmldir │ │ ├── Controls/ │ │ │ ├── ColorChart.qml │ │ │ ├── ColorSelector.qml │ │ │ ├── DelegateSelectionBox.qml │ │ │ ├── DelegateSelectionLine.qml │ │ │ ├── DirectionalLightPane.qml │ │ │ ├── ExifOrientedViewer.qml │ │ │ ├── ExpandableGroup.qml │ │ │ ├── FilterComboBox.qml │ │ │ ├── FloatingPane.qml │ │ │ ├── Group.qml │ │ │ ├── IntSelector.qml │ │ │ ├── KeyValue.qml │ │ │ ├── MScrollBar.qml │ │ │ ├── MSplitView.qml │ │ │ ├── MessageDialog.qml │ │ │ ├── NodeActions.qml │ │ │ ├── Panel.qml │ │ │ ├── SearchBar.qml │ │ │ ├── SelectionBox.qml │ │ │ ├── SelectionLine.qml │ │ │ ├── StatusBar.qml │ │ │ ├── StatusMessages.qml │ │ │ ├── TabPanel.qml │ │ │ ├── TextFileViewer.qml │ │ │ └── qmldir │ │ ├── DialogsFactory.qml │ │ ├── GraphEditor/ │ │ │ ├── AttributeControls/ │ │ │ │ ├── Choice.qml │ │ │ │ └── ChoiceMulti.qml │ │ │ ├── AttributeEditor.qml │ │ │ ├── AttributeItemDelegate.qml │ │ │ ├── AttributePin.qml │ │ │ ├── Backdrop.qml │ │ │ ├── ChunksListView.qml │ │ │ ├── CompatibilityBadge.qml │ │ │ ├── CompatibilityManager.qml │ │ │ ├── Edge.qml │ │ │ ├── GraphEditor.qml │ │ │ ├── GraphEditorSettings.qml │ │ │ ├── Node.qml │ │ │ ├── NodeChunks.qml │ │ │ ├── NodeDocumentation.qml │ │ │ ├── NodeEditor.qml │ │ │ ├── NodeFileBrowser.qml │ │ │ ├── NodeLog.qml │ │ │ ├── NodeStatistics.qml │ │ │ ├── NodeStatus.qml │ │ │ ├── ScriptEditor.qml │ │ │ ├── StatViewer.qml │ │ │ ├── TaskManager.qml │ │ │ └── qmldir │ │ ├── Homepage.qml │ │ ├── ImageGallery/ │ │ │ ├── ImageBadge.qml │ │ │ ├── ImageDelegate.qml │ │ │ ├── ImageGallery.qml │ │ │ ├── ImageGridView.qml │ │ │ ├── ImageListView.qml │ │ │ ├── IntrinsicDisplayDelegate.qml │ │ │ ├── IntrinsicsIndicator.qml │ │ │ ├── SensorDBDialog.qml │ │ │ └── qmldir │ │ ├── MaterialIcons/ │ │ │ ├── MLabel.qml │ │ │ ├── MaterialIcons.qml │ │ │ ├── MaterialLabel.qml │ │ │ ├── MaterialToolButton.qml │ │ │ ├── MaterialToolLabel.qml │ │ │ ├── MaterialToolLabelButton.qml │ │ │ ├── generate_material_icons.py │ │ │ └── qmldir │ │ ├── Shapes/ │ │ │ ├── Editor/ │ │ │ │ ├── Items/ │ │ │ │ │ ├── ShapeAttributeItem.qml │ │ │ │ │ ├── ShapeDataItem.qml │ │ │ │ │ ├── ShapeFileItem.qml │ │ │ │ │ ├── ShapeListAttributeItem.qml │ │ │ │ │ └── Utils/ │ │ │ │ │ └── ItemHeader.qml │ │ │ │ ├── ShapeEditor.qml │ │ │ │ └── ShapeEditorItem.qml │ │ │ ├── Viewer/ │ │ │ │ ├── Layers/ │ │ │ │ │ ├── BaseLayer.qml │ │ │ │ │ ├── CircleLayer.qml │ │ │ │ │ ├── LineLayer.qml │ │ │ │ │ ├── PointLayer.qml │ │ │ │ │ ├── RectangleLayer.qml │ │ │ │ │ ├── TextLayer.qml │ │ │ │ │ └── Utils/ │ │ │ │ │ └── Handle.qml │ │ │ │ ├── ShapeViewer.qml │ │ │ │ ├── ShapeViewerAttributeLayer.qml │ │ │ │ ├── ShapeViewerAttributeLoader.qml │ │ │ │ └── ShapeViewerLayer.qml │ │ │ └── qmldir │ │ ├── Utils/ │ │ │ ├── Clipboard.qml │ │ │ ├── Colors.qml │ │ │ ├── ExifOrientation.qml │ │ │ ├── ExpressionTextField.qml │ │ │ ├── Filepath.qml │ │ │ ├── Scene3DHelper.qml │ │ │ ├── SortFilterDelegateModel.qml │ │ │ ├── Transformations3DHelper.qml │ │ │ ├── errorHandler.js │ │ │ ├── format.js │ │ │ ├── qmldir │ │ │ └── request.js │ │ ├── Viewer/ │ │ │ ├── CameraResponseGraph.qml │ │ │ ├── CircleGizmo.qml │ │ │ ├── ColorCheckerEntity.qml │ │ │ ├── ColorCheckerPane.qml │ │ │ ├── ColorCheckerViewer.qml │ │ │ ├── FeaturesInfoOverlay.qml │ │ │ ├── FeaturesViewer.qml │ │ │ ├── FloatImage.qml │ │ │ ├── HdrImageToolbar.qml │ │ │ ├── ImageMetadataView.qml │ │ │ ├── LensDistortionToolbar.qml │ │ │ ├── MFeatures.qml │ │ │ ├── MSfMData.qml │ │ │ ├── MTracks.qml │ │ │ ├── PanoramaToolbar.qml │ │ │ ├── PanoramaViewer.qml │ │ │ ├── PhongImageViewer.qml │ │ │ ├── PhongImageViewerToolbar.qml │ │ │ ├── SequencePlayer.qml │ │ │ ├── SfmGlobalStats.qml │ │ │ ├── SfmStatsView.qml │ │ │ ├── TestAliceVisionPlugin.qml │ │ │ ├── TextViewer.qml │ │ │ ├── Viewer2D.qml │ │ │ └── qmldir │ │ ├── Viewer3D/ │ │ │ ├── BoundingBox.qml │ │ │ ├── DefaultCameraController.qml │ │ │ ├── DepthMapLoader.qml │ │ │ ├── EntityWithGizmo.qml │ │ │ ├── EnvironmentMapEntity.qml │ │ │ ├── Grid3D.qml │ │ │ ├── ImageOverlay.qml │ │ │ ├── Inspector3D.qml │ │ │ ├── Locator3D.qml │ │ │ ├── MaterialSwitcher.qml │ │ │ ├── Materials/ │ │ │ │ ├── SphericalHarmonicsEffect.qml │ │ │ │ ├── SphericalHarmonicsMaterial.qml │ │ │ │ ├── WireframeEffect.qml │ │ │ │ ├── WireframeMaterial.qml │ │ │ │ └── shaders/ │ │ │ │ ├── SphericalHarmonics.frag │ │ │ │ ├── SphericalHarmonics.vert │ │ │ │ ├── robustwireframe.frag │ │ │ │ └── robustwireframe.vert │ │ │ ├── MediaCache.qml │ │ │ ├── MediaLibrary.qml │ │ │ ├── MediaLoader.qml │ │ │ ├── MediaLoaderEntity.qml │ │ │ ├── MeshingBoundingBox.qml │ │ │ ├── SfMTransformGizmo.qml │ │ │ ├── SfmDataLoader.qml │ │ │ ├── TrackballGizmo.qml │ │ │ ├── TransformGizmo.qml │ │ │ ├── TransformGizmoPicker.qml │ │ │ ├── Viewer3D.qml │ │ │ ├── Viewer3DSettings.qml │ │ │ ├── ViewpointCamera.qml │ │ │ └── qmldir │ │ ├── WorkspaceView.qml │ │ └── main.qml │ ├── scene.py │ └── utils.py ├── requirements.txt ├── setup.py ├── setupInitScriptUnix.py ├── setupInitScriptWindows.py ├── start.bat ├── start.sh └── tests/ ├── __init__.py ├── appendTextAndFiles.mg ├── conftest.py ├── nodes/ │ ├── __init__.py │ └── test/ │ ├── Color.py │ ├── GroupAttributes.py │ ├── InputDynamicOutputs.py │ ├── NestedTest.py │ ├── Position.py │ ├── __init__.py │ ├── appendFiles.py │ ├── appendText.py │ └── ls.py ├── plugins/ │ └── meshroom/ │ ├── pluginA/ │ │ ├── PluginAInputInitNode.py │ │ ├── PluginAInputNode.py │ │ ├── PluginANodeA.py │ │ ├── PluginANodeB.py │ │ └── __init__.py │ ├── pluginB/ │ │ ├── PluginBNodeA.py │ │ ├── PluginBNodeB.py │ │ └── __init__.py │ ├── pluginC/ │ │ ├── PluginCNodeA.py │ │ └── __init__.py │ ├── pluginSubmitter/ │ │ ├── PluginSubmitter.py │ │ └── __init__.py │ └── sharedTemplate.mg ├── test_attributeChoiceParam.py ├── test_attributeDescDefaults.py ├── test_attributeKeyValues.py ├── test_attributeLambda.py ├── test_attributeShape.py ├── test_attributes.py ├── test_compatibility.py ├── test_compute.py ├── test_graph.py ├── test_graphIO.py ├── test_groupAttributes.py ├── test_invalidation.py ├── test_listAttribute.py ├── test_model.py ├── test_nodeAttributeChangedCallback.py ├── test_nodeAttributesFormatting.py ├── test_nodeCallbacks.py ├── test_nodeCommandLineFormatting.py ├── test_nodeDynamicOutputs.py ├── test_nodes.py ├── test_pipeline.py ├── test_plugins.py ├── test_submit.py └── utils.py ================================================ FILE CONTENTS ================================================ ================================================ FILE: .codecov.yml ================================================ # Codecov configuration for Meshroom # This configuration prevents CI from failing due to small coverage decreases # while maintaining quality standards codecov: # Require CI to pass before processing results require_ci_to_pass: yes coverage: status: project: default: # Allow coverage to drop by up to 1% without failing threshold: 1% # Set a minimum target coverage (adjust based on your current ~77%) target: 75% # Only check coverage on lines that are coverable base: auto # Ignore if no coverage files are uploaded if_no_uploads: error # Don't fail if coverage file not found if_not_found: success # Fail if CI failed if_ci_failed: error # Only run on these branches branches: - develop - master - main patch: default: # For new code, allow 2% threshold since new features may need refactoring threshold: 2% # New code should aim for 70% coverage (lower than overall project) target: 70% # Only run patch coverage on pull requests only_pulls: true if_no_uploads: error if_not_found: success if_ci_failed: error precision: 2 round: down range: "70...95" # Ignore certain files/directories that should not affect coverage ignore: - "setup.py" - "docs/" - "scripts/" - "bin/" - "localfarm/" # For now ignore localfarm as it has no coverage yet - "meshroom/submitters/" ================================================ FILE: .git-blame-ignore-revs ================================================ # Linting: Harmonize docstrings and comments 039e0620ad05d673a2ef9aa501e9a509219671c9 # Linting: Remove all trailing whitespaces 2c2b067f072856f2579c644b5f7858da0275be3c # [core] attribute: Apply linting b8c173ddd490dc3bf28b193697d675e44616c6f5 # Linting: Remove trailing whitespaces 04a425decc1b80f0c67e8c4c98c0062d73836684 # [core] Linting: Remove all trailing whitespaces 8be302115edca60c93b1e97de3f457d91c271666 # [tests] Linting: Remove trailing whitespaces 5fe886b6b08fa19082dc0e1bf837fa34c2e2de2d # [core] Linting: Remove remaining trailing whitespaces a44537b65a7c53c89c16e71ae207f37fa6554832 # Linting: Fix E203, E225, E231, E261, E302, E303 and W292 warnings 1b5664c8cc54c55fae58a5be9bf63e9af2f5af95 # [ui] Linting: Remove all trailing whitespaces 18d7f609b1a5cd7c43f970770374b649019c1e73 # [core] Linting: Fix import order aae05532b2409e3bd4c119646afc08656a916cb4 # [core] Linting: Remove all trailing whitespaces 81394d7def1fcbc08cbc2a8721bc1f0a86fe8cc6 # [desc] Linting: Remove trailing whitespaces 0a2ab8cab4f79191b0e7364416701ba561e75b6a # [desc] Linting: Fix order of the imported modules adf67e33533a75f5b280e0ee3fc0ece82307199e # [build] `setup.py`: Use double quotes everywhere 571de38ef1a9e72e6c1d2a997b60de5bd3caa5bf # [bin] `meshroom_batch`: Minor clean-up in the file 15d9ecd888faa7216cfc5d97d473f5717a3118a3 # [core] Linting following CI's flake8 report 9b4bd68d5aa9e5c3af5e4bfc4fe6aae06437ca88 # [tests] Linting following CI's flake8 report 9b6549cc1dd525658080303f7ad453bd4ec10f52 # [GraphEditor] Indentation fix 87c0cef605e4ef2b359d7e678155e79b65b2e762 # [qt6][qml] Clean-up code and harmonize comments 5a0b1c0c9547b0d00f3f10fae6994d6d8ea0b45e # [nodes] Linting: Clean-up files 4c0409f573c2694325b104c2686a1532f95cb9bc # Linting: Clean-up files 41e885d9ff38cd55772722376d5ef80ff908c559 # [Viewer] SequencePlayer: Clean-up: Harmonize syntax 42157809b90f5f6b275aa8ff9d7310c384ea395a # [Viewer] Clean-up: Harmonize syntax for the Viewer2D 9af65092b9e881c828430f54a73fb4522bc1e370 # [nodes] Harmonize the use of trailing commas across all the nodes 61a8dcd4e2878f80b2f320f2b1c3c9b41e999b82 # [nodes] Clean-up: Harmonize nodes' descriptions f2d67706511954aa3e1c026ecc858beb8c08f938 # [qml] Clean-up: Harmonize syntax across all files e463f0dce2455f47d5b066f9e9434ed94b2b282f # [GraphEditor] Clean-up: Harmonize syntax across all files e9d80611c7fe185623e5f276a41b7f2de23cb6fe # [ImageGallery] Clean-up: Harmonize syntax across all files 2bdf061d2e49f3e1513a59922dc33e69f68552cf # [Controls] Clean-up: Harmonize syntax across all files 2908aa94a3eda2de71f8c5e6cec8cd78280bbb09 # [Charts] Clean-up: Harmonize syntax across all files 856641bc9dc25271062dc94a66da4c08e00f88d1 # [Utils] Clean-up: Harmonize syntax across all files 8313e42d8c70e2494277e338ef8fd38824270231 # [Viewer] Clean-up: Harmonize syntax across all files 13b8266d14783a4c595c8b731c54fc9c61adfa92 # [Viewer3D] Clean-up: Harmonize syntax across all files 9d2974d2823fe5d6f400eb4658f67d0306b11ac8 ================================================ FILE: .github/FUNDING.yml ================================================ github: [alicevision] custom: ['https://alicevision.org/association/#donate'] ================================================ FILE: .github/ISSUE_TEMPLATE/bug_report.md ================================================ --- name: Bug report about: Create a report to help us improve title: "[bug]" labels: bug assignees: '' --- **Describe the bug** A clear and concise description of what the bug is. **To Reproduce** Steps to reproduce the behavior: 1. Go to '...' 2. Click on '....' 3. Scroll down to '....' 4. See error **Expected behavior** A clear and concise description of what you expected to happen. **Screenshots** If applicable, add screenshots to help explain your problem. **Log** If applicable, copy paste the relevant log output (please embed the text in a markdown code tag "\`\`\`" ) **Desktop (please complete the following and other pertinent information):** - OS: [e.g. win 10, osx, ] - Python version [e.g. 2.6] - Qt/PySide version [e.g. 6.8.2] - Meshroom version: please specify if you are using a release version or your own build - Binary version (if applicable) [e.g. 2023.3.0] - Commit reference (if applicable) [e.g. 08ddbe2] **Additional context** Add any other context about the problem here. ================================================ FILE: .github/ISSUE_TEMPLATE/feature_request.md ================================================ --- name: Feature request about: Suggest an idea for this project title: "[request]" labels: feature request assignees: '' --- **Is your feature request related to a problem? Please describe.** A clear and concise description of what the problem is. Ex. I am always frustrated when [...] **Describe the solution you'd like** A clear and concise description of what you want to happen. **Describe alternatives you have considered** A clear and concise description of any alternative solutions or features you have considered. **Additional context** Add any other context or screenshots about the feature request here. ================================================ FILE: .github/ISSUE_TEMPLATE/question_help.md ================================================ --- name: Question or help needed about: Ask question or for help for issues not related to program failures (e.g. "where I can find this feature", "my dataset is not reconstructed properly", "which parameter setting shall I use" etc...) title: "[question]" labels: type:question assignees: '' --- **Describe the problem** A clear and concise description of what the problem is. **Screenshots** If applicable, add screenshots to help explain your problem. **Dataset** If applicable, add a link or *few* images to help better understand where the problem may come from. **Log** If applicable, copy paste the relevant log output (please embed the text in a markdown code tag "\`\`\`" ) **Desktop (please complete the following and other pertinent information):** - OS: [e.g. win 10, osx, ] - Python version [e.g. 2.6] - Qt/PySide version [e.g. 6.8.2] - Meshroom version: please specify if you are using a release version or your own build - Binary version (if applicable) [e.g. 2023.3.0] - Commit reference (if applicable) [e.g. 08ddbe2] **Additional context** Add any other context about the problem here. ================================================ FILE: .github/pull_request_template.md ================================================ ## Description ## Features list ## Implementation remarks ================================================ FILE: .github/stale.yml ================================================ # Number of days of inactivity before an issue becomes stale daysUntilStale: 120 # Number of days of inactivity before a stale issue is closed daysUntilClose: 7 # Issues with these labels will never be considered stale exemptLabels: - "do not close" - "feature request" - "scope:doc" - "new feature" - "bug" # Label to use when marking an issue as stale staleLabel: stale # Comment to post when marking an issue as stale. Set to `false` to disable markComment: > This issue has been automatically marked as stale because it has not had recent activity. It will be closed if no further activity occurs. Thank you for your contributions. # Comment to post when closing a stale issue. Set to `false` to disable closeComment: > This issue is closed due to inactivity. Feel free to re-open if new information is available. ================================================ FILE: .github/workflows/continuous-integration.yml ================================================ name: Continuous Integration on: push: branches: - master - develop # Skip jobs when only documentation files are changed paths-ignore: - '**.md' - '**.rst' - 'docs/**' pull_request: paths-ignore: - '**.md' - '**.rst' - 'docs/**' env: CI: True PYTHONPATH: ${{ github.workspace }} jobs: build-linux: runs-on: ubuntu-latest strategy: matrix: python-version: [ 3.11 ] steps: - uses: actions/checkout@v3 - name: Set up Python ${{ matrix.python-version }} uses: actions/setup-python@v4 with: python-version: ${{ matrix.python-version }} - name: Install dependencies run: | python -m pip install --upgrade pip pip install flake8 pytest pytest-cov pip install -r requirements.txt -r dev_requirements.txt --timeout 45 - name: Lint with flake8 run: | # stop the build if there are Python syntax errors or undefined names flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics # exit-zero treats all errors as warnings. The GitHub editor is 127 chars wide flake8 . --count --exit-zero --max-complexity=10 --max-line-length=127 --statistics - name: Test with pytest run: | pytest tests/ pytest --cov --cov-report=xml --junitxml=junit.xml - name: Upload results to Codecov uses: codecov/codecov-action@v5 with: token: ${{ secrets.CODECOV_TOKEN }} - name: Upload test results to Codecov if: ${{ !cancelled() }} uses: codecov/test-results-action@v1 with: token: ${{ secrets.CODECOV_TOKEN }} - name: Set up Python 3.9 - meshroom_compute test uses: actions/setup-python@v4 with: python-version: 3.9 - name: Install dependencies (Python 3.9) - meshroom_compute test run: | python3.9 -m pip install --upgrade pip python3.9 -m pip install -r requirements.txt --timeout 45 - name: Run imports - meshroom_compute test run: | python3.9 bin/meshroom_compute -h build-windows: runs-on: windows-latest strategy: matrix: python-version: [ 3.11 ] steps: - uses: actions/checkout@v3 - name: Set up Python ${{ matrix.python-version }} uses: actions/setup-python@v4 with: python-version: ${{ matrix.python-version }} - name: Install dependencies run: | python -m pip install --upgrade pip pip install flake8 pytest pip install -r requirements.txt -r dev_requirements.txt --timeout 45 - name: Lint with flake8 run: | # stop the build if there are Python syntax errors or undefined names flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics # exit-zero treats all errors as warnings. The GitHub editor is 127 chars wide flake8 . --count --exit-zero --max-complexity=10 --max-line-length=127 --statistics - name: Test with pytest run: | pytest tests/ - name: Set up Python 3.9 - meshroom_compute test uses: actions/setup-python@v4 with: python-version: 3.9 - name: Install dependencies (Python 3.9) - meshroom_compute test run: | python3 -m pip install --upgrade pip python3 -m pip install -r requirements.txt --timeout 45 - name: Run imports - meshroom_compute test run: | python3 bin/meshroom_compute -h ================================================ FILE: .gitignore ================================================ # temporary files *~ # vim .*.swp # emacs *.flc \#*\# .\#* # xemacs # MacOS .DS_Store # Windows Thumbs.db # vscode .vscode # python *.pyc *.pyo __pycache__ # backup files *.json !*Config.json # datas or personal files /data /scripts /build /dist /dl # virtual environment /venv # tests /.tests /.pytest_cache # IDEs folders *.qmlproject* /nbproject .idea .cache .nfs* *.qmlc *.jsc # QtCreator project files *.cflags *.cxxflags *.creator* *.files *.includes *.dll *.lib install/qml/AliceVision/qmldir run.bat ================================================ FILE: .readthedocs.yaml ================================================ # .readthedocs.yaml # Read the Docs configuration file # See https://docs.readthedocs.io/en/stable/config-file/v2.html for details # Required version: 2 # Build HTML documentation with Sphinx sphinx: builder: html configuration: docs/source/conf.py # Python requirements python: install: - requirements: requirements.txt - requirements: dev_requirements.txt - requirements: docs/requirements.txt ================================================ FILE: CHANGES.md ================================================ # Meshroom Changelog For algorithmic changes related to the photogrammetric pipeline, please refer to [AliceVision changelog](https://github.com/alicevision/AliceVision/blob/develop/CHANGES.md). ## Meshroom 2025.1.0 (2025/08/18) Meshroom has now become a node-based visual programming toolbox for creating, managing, and executing complex data processing pipelines, with a new plugin architecture. Standard computer vision pipelines such as photogrammetry, camera tracking, HDR panorama, Lidar Meshing, Raw image files conversion and color calibration are now unified within the AliceVision plugin, featuring numerous improvements and optimizations. Additionally, new AI-powered capabilities include a semantic segmentation plugin and a collection of open-source extensions available via the new MeshroomHub: https://github.com/meshroomHub. This platform enables Gaussian Splatting, monocular depth estimation, and other exploratory features, welcoming developer contributions to expand and enhance these capabilities for upcoming releases. ### Highlights #### Meshroom New Features - **Advanced Plugin Architecture**: Dedicated sub-process isolation for Python nodes with independent local environments - **Integrated Development Tools**: - Built-in Python script editor - Node’s source code hot-reload for rapid node development iterations - **Enhanced GraphEditor**: - Dynamic output attributes enabling new workflow usages - New InputNode type enabling interactive evaluation without explicit computation - Multiple edge disconnection methods and node colorization for better user experience - Node notifications to attribute changes - **Enhanced 2D Viewer**: - Initial timeline integration with sequence playback controls - New Reflectance Transformation Imaging (RTI) Viewer: Interactive visualization of albedo and normal maps with real-time lighting control - New Home Page: featuring pipeline templates and quick access to recent projects. #### AliceVision Plugin New Features - New Pipelines - **Color Calibration**: Automated color correction from color charts - **Raw to EXR conversion**: Professional image format processing - **Object Reconstruction**: Targeted reconstruction with automatic object segmentation - **Turntable Object Reconstruction**: Streamlined workflow for rotating object capture - **360° Object Reconstruction**: Reconstruction of complete dual-sided scanning - **LiDAR Processing**: Native E57 file import with integrated mesh generation - **Multi-View Photometric Stereo**: Advanced surface detail reconstruction with multiple light sources for each viewpoint. - Pipelines Improvements - **Camera Tracking pipeline**: improved stability and reliability - **Introduced experimental fine-grained pipelines** for increased modularity and workflow flexibility - Core Enhancements - **Python Bindings Integration**: Enhanced AliceVision accessibility with native Python support for streamlined Machine Learning workflows #### New MrSegmentation Plugin AI segmentation nodes that identify and isolate image objects using natural language prompts, enabling intuitive content-aware processing through foundation models. #### MeshroomHub Plugins We're excited to introduce new experimental Machine Learning plugins available on [MeshroomHub](https://github.com/meshroomHub). These plugins showcase the future of Meshroom workflows, though they currently require developer setup and cannot be installed through the user interface yet. - mrGSplat: Gaussian Splat optimization and rendering - mrDepthEstimation: Monocular depth inference - mrDenseMotion: Optical flow estimation - mrRoma: Dense deep feature matching - mrIntrinsicImageDecomposition: Albedo, normals, and material extraction - mrDeblurring: Video deblurring - mrGeolocation: GPS extraction and geographic models download Based on [AliceVision 3.3.0](https://github.com/alicevision/AliceVision/tree/v3.3.0). ### Major Features - Add an "E57" importer node [PR](https://github.com/alicevision/Meshroom/pull/2308) - First node for Lidar Meshing [PR](https://github.com/alicevision/Meshroom/pull/2324) - New InputNode for nodes without computation and support for all param types in output (and no more limited to File type) [PR](https://github.com/alicevision/Meshroom/pull/2364) - [core] New dynamic output attributes [PR](https://github.com/alicevision/Meshroom/pull/2432) - First Homepage [PR](https://github.com/alicevision/Meshroom/pull/2452) - Qt6.6.3 / PySide6.6.3.1 upgrade [PR](https://github.com/alicevision/Meshroom/pull/2599) - New MultiView Photometric Stereo pipeline and new sfmFilter node [PR](https://github.com/alicevision/Meshroom/pull/2582) - [ui] Python Script Editor Improvements [PR](https://github.com/alicevision/Meshroom/pull/2587) - New local isolated computation for python nodes [PR](https://github.com/alicevision/Meshroom/pull/2703) - New Plugin Architecture for Node Registration [PR](https://github.com/alicevision/Meshroom/pull/2733) - [ui]: Introduction of multiple ways to remove Node Edges [PR](https://github.com/alicevision/Meshroom/pull/2644) - [core] Runtime-specific environments support [PR](https://github.com/alicevision/Meshroom/pull/2747) - [Photometric Stereo] MultiView fusion in Texturing [PR](https://github.com/alicevision/Meshroom/pull/2243) - Add a Python ScriptEditor in the GraphEditor tab [PR](https://github.com/alicevision/Meshroom/pull/2456) ### Features - Custom loader for .pc.ply point clouds [PR](https://github.com/alicevision/Meshroom/pull/2346) - Lidar nodes [PR](https://github.com/alicevision/Meshroom/pull/2365) - [ui] Viewer2D: Display lighting circle with auto detected sphere [PR](https://github.com/alicevision/Meshroom/pull/2413) - [ui] RGBA shortcuts for Image Viewer [PR](https://github.com/alicevision/Meshroom/pull/2425) - [ui] Shortcuts in Viewer2D and SequencePlayer [PR](https://github.com/alicevision/Meshroom/pull/2430) - [ui] node time computation and chunks count in node editor header [PR](https://github.com/alicevision/Meshroom/pull/1867) - [core/ui] Load image sequence from node's output in SequencePlayer [PR](https://github.com/alicevision/Meshroom/pull/2375) - [core] Forward the onAttributeChanged notification to all linked attributes [PR](https://github.com/alicevision/Meshroom/pull/2453) - add 3de undistortion models [PR](https://github.com/alicevision/Meshroom/pull/2446) - [GraphEditor] Base `ChoiceParam` model on attribute instead of description [PR](https://github.com/alicevision/Meshroom/pull/2494) - [core] Reference the attribute's instance type in its description [PR](https://github.com/alicevision/Meshroom/pull/2493) - [ui] Improve command line help message [PR](https://github.com/alicevision/Meshroom/pull/2518) - Added Pre and Post process functions on the Base Node [PR](https://github.com/alicevision/Meshroom/pull/2539) - [ui] Add and improve multiple UI tools for Photometric stereo [PR](https://github.com/alicevision/Meshroom/pull/2444) - Refactor Node selection for better UX and performance [PR](https://github.com/alicevision/Meshroom/pull/2605) - New SfMColorizing Node [PR](https://github.com/alicevision/Meshroom/pull/2610) - Update sfm pipeline to accept meshes [PR](https://github.com/alicevision/Meshroom/pull/2642) - Enable Fitting of selected Nodes in the Graph Editor when Fit is invoked [PR](https://github.com/alicevision/Meshroom/pull/2652) - Add relative paths to nodes as variables [PR](https://github.com/alicevision/Meshroom/pull/2629) - Node to inject survey points in the SFM [PR](https://github.com/alicevision/Meshroom/pull/2696) - [ui] AttributeEditor: Feature/attribute navigation buttons [PR](https://github.com/alicevision/Meshroom/pull/2716) - [ui] Homepage: Project can be removed with right click [PR](https://github.com/alicevision/Meshroom/pull/2724) - [ui] Viewer2D: Add the pixel (x,y) values in the toolbar (editable) [PR](https://github.com/alicevision/Meshroom/pull/2723) - [ui] AttributeEditor: Allow displaying attibute in corresponding viewport [PR](https://github.com/alicevision/Meshroom/pull/2722) - Update to Qt/PySide 6.8.3 [PR](https://github.com/alicevision/Meshroom/pull/2692) - Add a "ConvertDistortion" node [PR](https://github.com/alicevision/Meshroom/pull/2353) - [ui] Sync SequencePlayer and Viewer3D [PR](https://github.com/alicevision/Meshroom/pull/2360) - Viewer3D: Adjust bounding-box by moving faces [PR](https://github.com/alicevision/Meshroom/pull/2385) - [core/ui] Add support for PushButton attribute [PR](https://github.com/alicevision/Meshroom/pull/2382) - First version of For Loop implementation [PR](https://github.com/alicevision/Meshroom/pull/2504) - Generate depthmaps from sfmData and mesh [PR](https://github.com/alicevision/Meshroom/pull/2556) - [ui] Use the improved Sequence Player and enable it by default [PR](https://github.com/alicevision/Meshroom/pull/2557) - [AttributePin] Add tooltip to display type of attribute [PR](https://github.com/alicevision/Meshroom/pull/2527) - [core/ui] "Exposed" property added to attributeDesc [PR](https://github.com/alicevision/Meshroom/pull/2528) - Extract more metadata using exifTool [PR](https://github.com/alicevision/Meshroom/pull/2645) - Add equirectangular camera model in `CameraInit` [PR](https://github.com/alicevision/Meshroom/pull/2630) - Fix: Improve large project file loading performance [PR](https://github.com/alicevision/Meshroom/pull/2665) - UI: Redesign ChoiceParam UI component [PR](https://github.com/alicevision/Meshroom/pull/2656) - Create new pipeline for testing modular sfm [PR](https://github.com/alicevision/Meshroom/pull/2664) - [ui] Graph Editor Update: Quick Node Coloring with the Color Selector Tool [PR](https://github.com/alicevision/Meshroom/pull/2604) - [doc] README.md: Add DeepWiki link, the AI documentation you can talk to [PR](https://github.com/alicevision/Meshroom/pull/2792) ### Other Improvements - Start Development 2024.1.0 [PR](https://github.com/alicevision/Meshroom/pull/2268) - ImageSegmentation: add an option to choose between cpu and gpu [PR](https://github.com/alicevision/Meshroom/pull/2267) - [Viewer] Display error labels when an image cannot be loaded [PR](https://github.com/alicevision/Meshroom/pull/2250) - [MaterialIcons] Add script to generate the list of available MaterialIcons and update it [PR](https://github.com/alicevision/Meshroom/pull/2247) - Add option to keep input filename in imageSegmentation [PR](https://github.com/alicevision/Meshroom/pull/2288) - Add camera color spaces [PR](https://github.com/alicevision/Meshroom/pull/2251) - [docker] Fix link to download `libassimpsceneimport.so` in Docker images [PR](https://github.com/alicevision/Meshroom/pull/2310) - Added PLY to list of supported files in 3D viewer [PR](https://github.com/alicevision/Meshroom/pull/2316) - E57 importer is now generating multiple sfmData [PR](https://github.com/alicevision/Meshroom/pull/2318) - Added semantic logic to display multiple 3d objects [PR](https://github.com/alicevision/Meshroom/pull/2320) - [submitters] Update SimpleFarm configuration tags [PR](https://github.com/alicevision/Meshroom/pull/2348) - [ui] drag&drop: common behavior for graph editor and image gallery [PR](https://github.com/alicevision/Meshroom/pull/2342) - [core] Add new type of ChoiceParam that changes dynamically [PR](https://github.com/alicevision/Meshroom/pull/2350) - [ui] Add new FilterComboBox for ChoiceParam attributes [PR](https://github.com/alicevision/Meshroom/pull/2358) - [core/ui] Hide output attributes flagged for visualisation [PR](https://github.com/alicevision/Meshroom/pull/2369) - Update ripple constraints [PR](https://github.com/alicevision/Meshroom/pull/2374) - Hide disabled File attributes and their connections [PR](https://github.com/alicevision/Meshroom/pull/1925) - [ui] Sequence Player UX improvements (fps, slider, frame) [PR](https://github.com/alicevision/Meshroom/pull/2362) - [core] BugFix : Upgrade of Dynamic Choice Param fixed [PR](https://github.com/alicevision/Meshroom/pull/2380) - [ui] Bounding Box are usable in other nodes, not only Meshing [PR](https://github.com/alicevision/Meshroom/pull/2391) - [ui] Cut option available in GraphEditor [PR](https://github.com/alicevision/Meshroom/pull/2399) - [core] Set internal attributes when copy/pasting nodes [PR](https://github.com/alicevision/Meshroom/pull/2390) - [ImageGallery] Display CameraInit label and defaultLabel to avoid confusion [PR](https://github.com/alicevision/Meshroom/pull/2383) - [GraphEditor] Internal Custom Color Picker disabled when node is locked [PR](https://github.com/alicevision/Meshroom/pull/2384) - Bump requests from 2.27.1 to 2.32.0 [PR](https://github.com/alicevision/Meshroom/pull/2405) - [ui] Selected node header set to base color [PR](https://github.com/alicevision/Meshroom/pull/2401) - [ui] Remove intrinsic if not used by any viewpoint [PR](https://github.com/alicevision/Meshroom/pull/2395) - [ui] Right click on text element in AttributeEditor open Copy/Paste menu [PR](https://github.com/alicevision/Meshroom/pull/2366) - [ui] Fix BoundingBox visibility icon because of mapping name [PR](https://github.com/alicevision/Meshroom/pull/2386) - Add track coordinates [PR](https://github.com/alicevision/Meshroom/pull/2406) - [ui] Conversion of relative paths to absolute ones [PR](https://github.com/alicevision/Meshroom/pull/2412) - [core] Compare last saved date before saving to prevent overwrite [PR](https://github.com/alicevision/Meshroom/pull/2414) - Fix 3D Viewer zooming problem [PR](https://github.com/alicevision/Meshroom/pull/2379) - [ui] Use ExportAnimatedCamera output for image overlay in Viewer3D [PR](https://github.com/alicevision/Meshroom/pull/2398) - [GraphEditor] Eye on displayable node even if not computed [PR](https://github.com/alicevision/Meshroom/pull/2427) - [ui] Add "large" option to multiline string param [PR](https://github.com/alicevision/Meshroom/pull/2437) - [ui] Auto Update CameraInit when displaying node [PR](https://github.com/alicevision/Meshroom/pull/2431) - Fix compatibility upgrade issue [PR](https://github.com/alicevision/Meshroom/pull/2436) - Depth map filter: display normals if enabled [PR](https://github.com/alicevision/Meshroom/pull/2442) - [ui] do not use native dialog [PR](https://github.com/alicevision/Meshroom/pull/2439) - File export ordering [PR](https://github.com/alicevision/Meshroom/pull/2440) - [SequencePlayer] Fetching option added [PR](https://github.com/alicevision/Meshroom/pull/2415) - Provide access to the current frame from the graph [PR](https://github.com/alicevision/Meshroom/pull/2443) - Update ripple with "cuda" instead of "gpu" [PR](https://github.com/alicevision/Meshroom/pull/2448) - Provide access to the path of the currently displayed frame [PR](https://github.com/alicevision/Meshroom/pull/2449) - [Viewer] Fix all QML errors on the Sequence Player [PR](https://github.com/alicevision/Meshroom/pull/2451) - Remove plugin loading from core __init__ [PR](https://github.com/alicevision/Meshroom/pull/2458) - [ui] Sequence Player UI Modifications [PR](https://github.com/alicevision/Meshroom/pull/2445) - [ui] Add MESHROOM_USE_SEQUENCE_PLAYER environment variable [PR](https://github.com/alicevision/Meshroom/pull/2463) - Display ION container version in Meshroom [PR](https://github.com/alicevision/Meshroom/pull/2468) - Compute or Submit selected nodes [PR](https://github.com/alicevision/Meshroom/pull/2459) - Add new SfMExpanding node [PR](https://github.com/alicevision/Meshroom/pull/2416) - Add squeeze option [PR](https://github.com/alicevision/Meshroom/pull/2466) - [Viewer] Current frame for Sequence should not be set during changes of Image Gallery [PR](https://github.com/alicevision/Meshroom/pull/2472) - Remove some computers even for normal tasks [PR](https://github.com/alicevision/Meshroom/pull/2479) - [GraphEditor] Implementation of Recompute Button [PR](https://github.com/alicevision/Meshroom/pull/2473) - [core] Attribute: Directly access description's type in `getType()` [PR](https://github.com/alicevision/Meshroom/pull/2490) - [Viewer] Update error values for QtAV's `EStatus` enum [PR](https://github.com/alicevision/Meshroom/pull/2491) - [GraphEditor] Improve visibility of chunks in progress bar [PR](https://github.com/alicevision/Meshroom/pull/2507) - [ui] Correctly lose focus on `StringParam` when clicking outside of its text field [PR](https://github.com/alicevision/Meshroom/pull/2512) - Multiple shots: Align and merge multiple SfM from feature matches [PR](https://github.com/alicevision/Meshroom/pull/2484) - Homepage Quick Adjustments [PR](https://github.com/alicevision/Meshroom/pull/2520) - Add locks for intrinsics [PR](https://github.com/alicevision/Meshroom/pull/2517) - sfmTransform: Add option to lineup camera motion with object/lidar given an external camera pose [PR](https://github.com/alicevision/Meshroom/pull/2524) - [ui] Open project from browser in homepage & quick adjustments [PR](https://github.com/alicevision/Meshroom/pull/2525) - [ui] Minor UI modifications [PR](https://github.com/alicevision/Meshroom/pull/2530) - [ui] Fix click on Category in Node Menu to keep the nodes displayed [PR](https://github.com/alicevision/Meshroom/pull/2526) - [core] Simplify attribute invalidation in nodes' descriptions [PR](https://github.com/alicevision/Meshroom/pull/2523) - UI Changes [PR](https://github.com/alicevision/Meshroom/pull/2531) - [AttributeItemDelegate] Position the attribute description tooltip [PR](https://github.com/alicevision/Meshroom/pull/2532) - [ui] Add View Image Gallery Parameter [PR](https://github.com/alicevision/Meshroom/pull/2541) - [core] Simplify node descriptions [PR](https://github.com/alicevision/Meshroom/pull/2538) - Use export distortion and new segmentation node in templates [PR](https://github.com/alicevision/Meshroom/pull/2549) - Add wireframe for Qt6 [PR](https://github.com/alicevision/Meshroom/pull/2561) - Change picking behavior for qt6 upgrade [PR](https://github.com/alicevision/Meshroom/pull/2564) - [qt6] Fix 8Bits image viewer zoom/fit [PR](https://github.com/alicevision/Meshroom/pull/2565) - [blender] Adapt `ScenePreview`'s Blender script to pixel ratio [PR](https://github.com/alicevision/Meshroom/pull/2572) - Update panorama display [PR](https://github.com/alicevision/Meshroom/pull/2573) - Fix attribute value change propagation and callback handling [PR](https://github.com/alicevision/Meshroom/pull/2586) - Tracking pipelines segmentation update [PR](https://github.com/alicevision/Meshroom/pull/2583) - [qt6]|Viewer3D] Fix mouse for camera controller [PR](https://github.com/alicevision/Meshroom/pull/2566) - Discard attribute changed callbacks during graph loading [PR](https://github.com/alicevision/Meshroom/pull/2598) - Split `meshroom.core.desc` module into a package with submodules [PR](https://github.com/alicevision/Meshroom/pull/2592) - [ui] Minor UI stabilization fixes for Qt 6 [PR](https://github.com/alicevision/Meshroom/pull/2606) - [ui] Fix field of view functions for tall images [PR](https://github.com/alicevision/Meshroom/pull/2609) - [Viewer3D] Apply the pixel aspect ratio for the Frame Overlay [PR](https://github.com/alicevision/Meshroom/pull/2533) - [ui] Improve Search Bar component [PR](https://github.com/alicevision/Meshroom/pull/2581) - [BugFix] File save dialog now requires a valid filename [PR](https://github.com/alicevision/Meshroom/pull/2602) - [GraphEditor] AttributeItemDelegate: Use MaterialLabel for uncomputed attributes [PR](https://github.com/alicevision/Meshroom/pull/2616) - CI: add codecov [PR](https://github.com/alicevision/Meshroom/pull/2618) - Sfm Bootstraping parameterization [PR](https://github.com/alicevision/Meshroom/pull/2619) - Fix Qt6-induced issues [PR](https://github.com/alicevision/Meshroom/pull/2620) - [ui] GraphEditor: Address Key Event Conflicts in Node Menu [PR](https://github.com/alicevision/Meshroom/pull/2622) - [ui] Add Validation for Save file path accessibility [PR](https://github.com/alicevision/Meshroom/pull/2625) - [ui] NodeEditor: Addressed Tab Retention when switching Node selection [PR](https://github.com/alicevision/Meshroom/pull/2624) - Add support for QML debugging/profiling [PR](https://github.com/alicevision/Meshroom/pull/2623) - [GraphEditor] Fix injections into signal handlers with JS functions [PR](https://github.com/alicevision/Meshroom/pull/2627) - [ui] "About" dialog: Fix some display issues [PR](https://github.com/alicevision/Meshroom/pull/2640) - Update version number and copyrights [PR](https://github.com/alicevision/Meshroom/pull/2639) - SelectionBox: Fixed the offset on the selection box highlight appearing in the Graph Editor when dragging to select Nodes [PR](https://github.com/alicevision/Meshroom/pull/2647) - [ui] Moved Auto-Layout Depth Settings under Graph Editor Menu [PR](https://github.com/alicevision/Meshroom/pull/2646) - Enable merge of multiple sfmDatas [PR](https://github.com/alicevision/Meshroom/pull/2654) - [ui][fix] Edge: Fixing an issue with mouse event on Custom EdgeMouseArea causing Crash [PR](https://github.com/alicevision/Meshroom/pull/2650) - [ui] Refactor the access to the list of recent project files [PR](https://github.com/alicevision/Meshroom/pull/2637) - Mask processing node [PR](https://github.com/alicevision/Meshroom/pull/2658) - Export Maya .mel Script [PR](https://github.com/alicevision/Meshroom/pull/2617) - Refactor Graph de/serialization [PR](https://github.com/alicevision/Meshroom/pull/2612) - Node: Propagate attribute change via `valueChanged` signal [PR](https://github.com/alicevision/Meshroom/pull/2657) - [qml] Fix QML warnings related to chunks [PR](https://github.com/alicevision/Meshroom/pull/2673) - Add maya scene export [PR](https://github.com/alicevision/Meshroom/pull/2674) - NodeAPI: Trigger node creation callback only for explicit new node creation [PR](https://github.com/alicevision/Meshroom/pull/2671) - [ui] app: Register components to QML before instantiating the engine [PR](https://github.com/alicevision/Meshroom/pull/2676) - [ui] Application: fix save-as dialog not working properly (Qt6.7+) [PR](https://github.com/alicevision/Meshroom/pull/2683) - [GraphEditor] Only display "Pipelines" menu when templates are available [PR](https://github.com/alicevision/Meshroom/pull/2678) - [qml] Fix QML warnings when dropping project files into the Graph Editor [PR](https://github.com/alicevision/Meshroom/pull/2680) - Export USD Node [PR](https://github.com/alicevision/Meshroom/pull/2667) - [ui] AttributeEditor: Generic TextField param editor improvements [PR](https://github.com/alicevision/Meshroom/pull/2686) - ChoiceParam: add option to serialize overriden values [PR](https://github.com/alicevision/Meshroom/pull/2682) - [core] Node: Status should be `NONE` when there is no chunk [PR](https://github.com/alicevision/Meshroom/pull/2695) - Move nodes and templates to AliceVision's repository [PR](https://github.com/alicevision/Meshroom/pull/2697) - Remove internal and no longer used files [PR](https://github.com/alicevision/Meshroom/pull/2711) - Modernize to python 3.9 using flynt and pyupgrade [PR](https://github.com/alicevision/Meshroom/pull/2710) - [doc] README: Clarified distinction between Meshroom engine, user interface, and plugins [PR](https://github.com/alicevision/Meshroom/pull/2718) - Use shutil to load nvidia-smi [PR](https://github.com/alicevision/Meshroom/pull/2721) - [ui] Viewer2D can display the content of tracks files [PR](https://github.com/alicevision/Meshroom/pull/2720) - [ui] [fix] Attribute: Fix the qml warnings on intrisincs [PR](https://github.com/alicevision/Meshroom/pull/2739) - [ui] Application: Use CamelCase and disable tooltips when menus are disabled [PR](https://github.com/alicevision/Meshroom/pull/2742) - ListAttribute: fix methods not considering connected attribute's value [PR](https://github.com/alicevision/Meshroom/pull/2660) - [fix] remove targetSize in viewer2d which was removed in qtAliceVision [PR](https://github.com/alicevision/Meshroom/pull/2746) - [ui] Homepage: Update logos of sponsors [PR](https://github.com/alicevision/Meshroom/pull/2729) - [ui] Rework of MessageDialog for CompatibilityManager and SensorDBDialog [PR](https://github.com/alicevision/Meshroom/pull/2537) - [qml] Fix some minor QML warnings [PR](https://github.com/alicevision/Meshroom/pull/2756) - Add support for `ALICEVISION_LIBPATH` environment variable [PR](https://github.com/alicevision/Meshroom/pull/2757) - [docker] minor updates [PR](https://github.com/alicevision/Meshroom/pull/2765) - [core] plugins: Add support for virtual environments on Windows [PR](https://github.com/alicevision/Meshroom/pull/2768) - [core] Adding rangeBlocksCount to `Parallelization` [PR](https://github.com/alicevision/Meshroom/pull/2767) - Bump requests from 2.32.0 to 2.32.4 [PR](https://github.com/alicevision/Meshroom/pull/2743) - Fix colorHueComponent slider background [PR](https://github.com/alicevision/Meshroom/pull/2788) - [core] plugins: Look recursively for "lib" directories in Linux venv [PR](https://github.com/alicevision/Meshroom/pull/2777) - [core] plugins: Virtual environments should be named "venv" instead of having the plugin's name [PR](https://github.com/alicevision/Meshroom/pull/2793) - [qml] Minor UI fixes [PR](https://github.com/alicevision/Meshroom/pull/2783) - [qml] Use native FileDialogs [PR](https://github.com/alicevision/Meshroom/pull/2784) - Set the default environment variables for the color chart detection models [PR](https://github.com/alicevision/Meshroom/pull/2796) - [ui] Remove the `Live Reconstruction` and `Augment Reconstruction` features [PR](https://github.com/alicevision/Meshroom/pull/2786) - Improve behaviour when dropping folders [PR](https://github.com/alicevision/Meshroom/pull/2797) - [core] plugins: Load plugin's configuration file upon its initialisation [PR](https://github.com/alicevision/Meshroom/pull/2778) - [core] plugins: Downgrade the log level when loading the config file [PR](https://github.com/alicevision/Meshroom/pull/2798) ### Bugfixes - Fix duplicated icon in MaterialIcons [PR](https://github.com/alicevision/Meshroom/pull/2277) - Correctly delete thread pools when exiting Meshroom with Python 3.9 [PR](https://github.com/alicevision/Meshroom/pull/2286) - [Viewer] Viewer: Fix various issues with the 2D Viewer [PR](https://github.com/alicevision/Meshroom/pull/2283) - Use the correct response file to display the graph of the Camera Response Function [PR](https://github.com/alicevision/Meshroom/pull/2282) - Update `ListAttributes` identically when removing edges or nodes [PR](https://github.com/alicevision/Meshroom/pull/2280) - Upgrade intrinsics for distortion [PR](https://github.com/alicevision/Meshroom/pull/2349) - [ui] Correctly display images from node outputs even if there is no `CameraInit` node [PR](https://github.com/alicevision/Meshroom/pull/2363) - [ui] Scroll available in FilterComboBox [PR](https://github.com/alicevision/Meshroom/pull/2376) - [Viewer] fix lens distortion viewer status when switching between projects [PR](https://github.com/alicevision/Meshroom/pull/2377) - [ui] Fix drag and drop of heavy number of frames [PR](https://github.com/alicevision/Meshroom/pull/2378) - SequencePlayer: Forbid "selecting" an invalid frame number [PR](https://github.com/alicevision/Meshroom/pull/2388) - [ui] Prevent Feature Points to display on external images [PR](https://github.com/alicevision/Meshroom/pull/2389) - [ui/core] Fix get latest SfM node for previz [PR](https://github.com/alicevision/Meshroom/pull/2396) - [nodes/ui] Fix ExportAnimatedCamera outputs for ScenePreview use [PR](https://github.com/alicevision/Meshroom/pull/2420) - [fix] Various fixes [PR](https://github.com/alicevision/Meshroom/pull/2419) - Prevent updates of the latest SfM node when the graph's topology is dirty [PR](https://github.com/alicevision/Meshroom/pull/2435) - [Utils] `getTimeStr`: Round up the number of minutes correctly [PR](https://github.com/alicevision/Meshroom/pull/2254) - [ui] Graph: Connect all chunks when setting a graph for the first time [PR](https://github.com/alicevision/Meshroom/pull/2454) - [core] Exclude edges from `InputNode` nodes in `dfsToProcess` [PR](https://github.com/alicevision/Meshroom/pull/2455) - [core] Values of ChoiceParam should be a list, Error message added for initialisation [PR](https://github.com/alicevision/Meshroom/pull/2469) - Some fixes for dynamic output attributes [PR](https://github.com/alicevision/Meshroom/pull/2470) - [ui] Fix local computation of subgraphs for unsaved projects [PR](https://github.com/alicevision/Meshroom/pull/2471) - [ui] Fix Camera Init Group Index should stay the same at adding or removing CameraInit events [PR](https://github.com/alicevision/Meshroom/pull/2474) - [Viewer2D] Only reset index of currentFrame if the currentFrame is after max of frameRange [PR](https://github.com/alicevision/Meshroom/pull/2480) - [ui] setSfm only depends on nodes with category "sfm" and CameraInit should be set only if it is different from the current one [PR](https://github.com/alicevision/Meshroom/pull/2476) - [GraphEditor] AttributeItemDelegate: Return valid component for `PushButton` [PR](https://github.com/alicevision/Meshroom/pull/2482) - Initialize `core` plugins at different moments [PR](https://github.com/alicevision/Meshroom/pull/2487) - [ui] app: Correctly reload list of available templates [PR](https://github.com/alicevision/Meshroom/pull/2499) - [core] Catch exception for calls to optional descriptor method on node creation [PR](https://github.com/alicevision/Meshroom/pull/2500) - [ui] Improve sequence display [PR](https://github.com/alicevision/Meshroom/pull/2502) - [ui] GraphEditor.newNodeMenu: fix unstable menu height [PR](https://github.com/alicevision/Meshroom/pull/2511) - [ui] Add proper distinction between the main window and the application [PR](https://github.com/alicevision/Meshroom/pull/2521) - [ui] Fix function evaluations in invalid QML context and minor fixes [PR](https://github.com/alicevision/Meshroom/pull/2519) - Fix Several Compatibility Nodes Operations [PR](https://github.com/alicevision/Meshroom/pull/2506) - [main] Fix imagesFolder variable in order to save when gallery is not empty [PR](https://github.com/alicevision/Meshroom/pull/2535) - [bin] Import correct `Graph` objects for `meshroom_batch` [PR](https://github.com/alicevision/Meshroom/pull/2536) - Fix homepage SplitViews [PR](https://github.com/alicevision/Meshroom/pull/2545) - [core] Check provided template folder exists before attempting to load it [PR](https://github.com/alicevision/Meshroom/pull/2552) - [img] Remove incorrect sRGB profile from UiO logo [PR](https://github.com/alicevision/Meshroom/pull/2555) - [ui] multiple fixes related to split view and node status checks [PR](https://github.com/alicevision/Meshroom/pull/2568) - [ui] Various minor UI fixes [PR](https://github.com/alicevision/Meshroom/pull/2563) - [core] Node: Do not automatically upgrade unknown nodes in templates [PR](https://github.com/alicevision/Meshroom/pull/2558) - [GraphEditor] Node: Check if unexposed `ListAttributes` contain links [PR](https://github.com/alicevision/Meshroom/pull/2578) - [GraphEditor] Edge: Correctly update the `EdgeMouseArea` when moving nodes [PR](https://github.com/alicevision/Meshroom/pull/2613) - Fix projects disappearing from the list of recent projects [PR](https://github.com/alicevision/Meshroom/pull/2615) - [ImageGallery] Intrinsics table: Always fully instantiate the model before populating it [PR](https://github.com/alicevision/Meshroom/pull/2655) - [ui] Graph: In minimal refresh, do not poll files for chunks run locally [PR](https://github.com/alicevision/Meshroom/pull/2672) - Fix Meshroom App CLI `latest` option [PR](https://github.com/alicevision/Meshroom/pull/2675) - [bin] `meshroom_batch`: Stop using removed `defaultCacheFolder` [PR](https://github.com/alicevision/Meshroom/pull/2715) - [desc] Import `CREATE_NEW_PROCESS_GROUP` flag from `subprocess` [PR](https://github.com/alicevision/Meshroom/pull/2719) - [ui] Reconstruction: Restore the `Slot` status of the `clear` method [PR](https://github.com/alicevision/Meshroom/pull/2732) - [core] attribute: Fix `hasOutputConnections` for ListAttributes [PR](https://github.com/alicevision/Meshroom/pull/2731) - Fix elapsed time when there is only one chunk [PR](https://github.com/alicevision/Meshroom/pull/2734) - bugfix ExecMode status [PR](https://github.com/alicevision/Meshroom/pull/2737) - [ui] Update node status when modified [PR](https://github.com/alicevision/Meshroom/pull/2738) - [ui] [fix] MediaLibrary: Check if the model.source is actually an Attribute… [PR](https://github.com/alicevision/Meshroom/pull/2736) - [ui] [fix] Viewer2D: Failure on MousePosition on some edge cases [PR](https://github.com/alicevision/Meshroom/pull/2741) - [core] Templates test: Remove outdated `unregisterNodeType` import [PR](https://github.com/alicevision/Meshroom/pull/2750) - [ui] GraphEditor fix: Remove useless link between height and implicitHeight [PR](https://github.com/alicevision/Meshroom/pull/2749) - [core] Templates test: Access node descriptor from `NodePlugin` object [PR](https://github.com/alicevision/Meshroom/pull/2751) - [core] Stop checking for templates in "pipelines" folder [PR](https://github.com/alicevision/Meshroom/pull/2752) - [ui] [fix] Viewer2D: using the keyboard shortcuts (r,g,b,a) break the channelBox combobox [PR](https://github.com/alicevision/Meshroom/pull/2753) - [ui] Reconstruction: Fix setup of temporary `CameraInit` nodes [PR](https://github.com/alicevision/Meshroom/pull/2762) - [core] [fix] Fix camera see through not working when multiple cameraInit and image overlay dind't display anythind [PR](https://github.com/alicevision/Meshroom/pull/2761) - [core] desc.node: Ensure all paths are sent to the command line as POSIX strings [PR](https://github.com/alicevision/Meshroom/pull/2760) - [ui] Nodes: Update the deprecated import of QGraphicEffects. [PR](https://github.com/alicevision/Meshroom/pull/2755) - [ui] Import images: Fix that trying to import images twic, the dialog… [PR](https://github.com/alicevision/Meshroom/pull/2763) - Meshing: boundingBox working with qt6 [PR](https://github.com/alicevision/Meshroom/pull/2766) - Fix manual frame selection in viewer 2D [PR](https://github.com/alicevision/Meshroom/pull/2769) - [ui] app: Correctly evaluate env vars that enable/disable components [PR](https://github.com/alicevision/Meshroom/pull/2772) - Fix for QFontDatabase crash on exit [PR](https://github.com/alicevision/Meshroom/pull/2776) - [ui] Add project to recent projects when dropping a file [PR](https://github.com/alicevision/Meshroom/pull/2483) - [ui] fix: Overlay image does not work on pipeline "Photogrametry experimental" [PR](https://github.com/alicevision/Meshroom/pull/2780) - [core] Parallelization: the cmdline suffix should be at the end [PR](https://github.com/alicevision/Meshroom/pull/2794) ### CI, Documentation and Build - Add environment variable for the CI [PR](https://github.com/alicevision/Meshroom/pull/2492) - Adding new tutorial [PR](https://github.com/alicevision/Meshroom/pull/2546) - [ci] Use GitHub's workflows for the Windows CI instead of appveyor [PR](https://github.com/alicevision/Meshroom/pull/2551) - [ci] Codecov: enable support for test run reports [PR](https://github.com/alicevision/Meshroom/pull/2659) - change git clone link to use https link in "get the project" [PR](https://github.com/alicevision/Meshroom/pull/2700) - [ci] Update Python version from 3.9.13 to 3.11 [PR](https://github.com/alicevision/Meshroom/pull/2758) - [docker] Add Dockerfiles for Rocky 9 and handle Qt 6 installation [PR](https://github.com/alicevision/Meshroom/pull/2626) - [doc] Update `INSTALL.md` and `README.md` files [PR](https://github.com/alicevision/Meshroom/pull/2787) - [build] Fixes for the generation of Meshroom's executable [PR](https://github.com/alicevision/Meshroom/pull/2770) - [doc] README.md: Add DeepWiki link, the AI documentation you can talk to [PR](https://github.com/alicevision/Meshroom/pull/2792) ### Contributors [cbentejac](https://github.com/cbentejac), [demoulinv](https://github.com/demoulinv), [dependabot[bot]](https://github.com/apps/dependabot), [dyster](https://github.com/dyster), [elyasbny](https://github.com/elyasbny), [emmanuel-ferdman](https://github.com/emmanuel-ferdman), [fabiencastan](https://github.com/fabiencastan), [gregoire-dl](https://github.com/gregoire-dl), [jmelou](https://github.com/jmelou), [Just-Kiel](https://github.com/Just-Kiel), [mh0g](https://github.com/mh0g), [natowi](https://github.com/natowi), [nicolas-lambert-tc](https://github.com/nicolas-lambert-tc), [sbrood](https://github.com/sbrood), [servantftransperfect](https://github.com/servantftransperfect), [Sh1r0Yaksha](https://github.com/Sh1r0Yaksha), [waaake](https://github.com/waaake), [yann-lty](https://github.com/yann-lty) ## Meshroom 2023.3.0 (2023/12/07) Based on [AliceVision 3.2.0](https://github.com/alicevision/AliceVision/tree/v3.2.0). ### Major Features - New node for semantic image segmentation [PR](https://github.com/alicevision/Meshroom/pull/2076) - Support pixel aspect ratio (no UI) [PR](https://github.com/alicevision/Meshroom/pull/2079) - Noise reduction in HDR merging [PR](https://github.com/alicevision/Meshroom/pull/2072) ### Features - [ui] 2D viewer: image sequence player [PR](https://github.com/alicevision/Meshroom/pull/1989) - [bin] meshroom_batch: support multiple init nodes [PR](https://github.com/alicevision/Meshroom/pull/2137) - [nodes] StructureFromMotion: Automatic alignment of the 3D reconstruction [PR](https://github.com/alicevision/Meshroom/pull/2199) - New node for intrinsics and rig calibration using a multiview acquisition of a checkerboard [PR](https://github.com/alicevision/Meshroom/pull/2171) - New Nodal Camera Tracking pipeline [PR](https://github.com/alicevision/Meshroom/pull/2200) - Manage LCP in imageProcessing [PR](https://github.com/alicevision/Meshroom/pull/2042) - [Viewer3D] Add slider to display cameras based on their resection IDs [PR](https://github.com/alicevision/Meshroom/pull/2235) ### Other Improvements - Start Development 2023.3 [PR](https://github.com/alicevision/Meshroom/pull/2085) - Node to split reconstructed and not reconstructed cameras [PR](https://github.com/alicevision/Meshroom/pull/1974) - [core] Execute command line from node folder [PR](https://github.com/alicevision/Meshroom/pull/2093) - [core] Add brackets option for GroupAttribute [PR](https://github.com/alicevision/Meshroom/pull/2094) - Update Qt version to 5.15.2 [PR](https://github.com/alicevision/Meshroom/pull/1882) - [pipelines] Panorama: Publish the panorama preview [PR](https://github.com/alicevision/Meshroom/pull/2106) - [nodes] HDR Fusion: Correctly detect the number of brackets when there are several intrinsics [PR](https://github.com/alicevision/Meshroom/pull/2104) - [nodes] ImageSegmentation: use ChoiceParam instead of ListAttribute for validClasses [PR](https://github.com/alicevision/Meshroom/pull/2109) - [Panorama] Enforce priors after estimation [PR](https://github.com/alicevision/Meshroom/pull/1926) - tolerant bracket size selection [PR](https://github.com/alicevision/Meshroom/pull/2113) - [nodes] HDR Fusion: Do not send `nbBrackets` parameter to the command line when the bracket detection is automatic [PR](https://github.com/alicevision/Meshroom/pull/2117) - [nodes] Remove limits on outliers for brackets detection [PR](https://github.com/alicevision/Meshroom/pull/2118) - [nodes] LdrToHdrSampling: Exclude outliers from size computation [PR](https://github.com/alicevision/Meshroom/pull/2119) - [nodes] HDR Fusion: Select group with largest bracket number in case of equality [PR](https://github.com/alicevision/Meshroom/pull/2121) - [nodes] new exportLevels option in PanoramaPostProcessing [PR](https://github.com/alicevision/Meshroom/pull/2133) - [ui] GraphEditor: Minor UI changes [PR](https://github.com/alicevision/Meshroom/pull/2125) - [pipelines] publish downscaled panorama levels [PR](https://github.com/alicevision/Meshroom/pull/2147) - [nodes] HDR Fusion: Use the same bracket detection as in AliceVision [PR](https://github.com/alicevision/Meshroom/pull/2154) - AttributeEditor: Flag attributes with invalid values [PR](https://github.com/alicevision/Meshroom/pull/2141) - [pipelines] Add colors for CameraTracking and Photog+CamTrack templates [PR](https://github.com/alicevision/Meshroom/pull/2114) - [pipelines] add ImageSegmentation node to tracking pipelines [PR](https://github.com/alicevision/Meshroom/pull/2164) - Camera exposure update [PR](https://github.com/alicevision/Meshroom/pull/2159) - PanoramaInit: remove fake dependency [PR](https://github.com/alicevision/Meshroom/pull/2110) - [nodes] Masking: Handle file extensions for masks and mask inversion for `ImageSegmentation` [PR](https://github.com/alicevision/Meshroom/pull/2165) - [nodes] KeyframeSelection: Add `minBlockSize` param for multi-threading [PR](https://github.com/alicevision/Meshroom/pull/2161) - [nodes] KeyframeSelection: Add support for masks [PR](https://github.com/alicevision/Meshroom/pull/2167) - KeyframeSelection: Flag `outputExtension` attribute when it is set to "none" for video inputs [PR](https://github.com/alicevision/Meshroom/pull/2163) - [blender] apply masks to scene preview [PR](https://github.com/alicevision/Meshroom/pull/2170) - Add automatic method for HDR calibration [PR](https://github.com/alicevision/Meshroom/pull/2169) - Multiple UI Improvements [PR](https://github.com/alicevision/Meshroom/pull/2173) - [ui] FloatImageViewer: adapt resolution to zoom [PR](https://github.com/alicevision/Meshroom/pull/2148) - [nodes] StructureFromMotion: Add new `logIntermediateSteps` parameter [PR](https://github.com/alicevision/Meshroom/pull/2182) - sfm bootstraping [PR](https://github.com/alicevision/Meshroom/pull/2011) - [nodes] PanoramaPostProcessing: Add attributes to change the outputs' names [PR](https://github.com/alicevision/Meshroom/pull/2193) - [nodes] Meshing: expose minVis param [PR](https://github.com/alicevision/Meshroom/pull/2196) - [ui] SequencePlayer: minor adjustments (fps, icon, play) [PR](https://github.com/alicevision/Meshroom/pull/2197) - [pipelines] Rename Nodal Tracking to Nodal Camera Tracking [PR](https://github.com/alicevision/Meshroom/pull/2207) - [nodes] DepthMap: increase size of blocks [PR](https://github.com/alicevision/Meshroom/pull/2203) - [ui] ImageGallery: Add "Remove All Images" menu to clear all images [PR](https://github.com/alicevision/Meshroom/pull/2221) - [bin] `meshroom_batch`: Add support for relative input and output paths [PR](https://github.com/alicevision/Meshroom/pull/2218) - [pipelines] CamTrack: Add new template without calibration and update some parameters [PR](https://github.com/alicevision/Meshroom/pull/2216) - Input color space setting [PR](https://github.com/alicevision/Meshroom/pull/2219) - Use new SfmDataEntity plugin instead of AlembicEntity [PR](https://github.com/alicevision/Meshroom/pull/2208) - [Viewer3D] Remove AlembicLoader file [PR](https://github.com/alicevision/Meshroom/pull/2228) - [pipelines] CamTrack: Update default params for keyframes SfM [PR](https://github.com/alicevision/Meshroom/pull/2227) - [pipelines] PhotogAndCamTrack: Disable automatic alignment in SfM [PR](https://github.com/alicevision/Meshroom/pull/2238) - Automatic reorientation [PR](https://github.com/alicevision/Meshroom/pull/2236) - Minor code clean-up and QML warning and error fixes [PR](https://github.com/alicevision/Meshroom/pull/2226) - Add ancestor images info in view [PR](https://github.com/alicevision/Meshroom/pull/2242) - [Viewer3D] Connect any change of the selected view ID to the SfmDataLoader [PR](https://github.com/alicevision/Meshroom/pull/2237) - New utility nodes to create camera rigs and merge two sfmData [PR](https://github.com/alicevision/Meshroom/pull/2214) - [pipelines] Add image segmentation to the Nodal Camera Tracking template [PR](https://github.com/alicevision/Meshroom/pull/2266) ### Bugfixes - QML: Fix minor coercion error and warning [PR](https://github.com/alicevision/Meshroom/pull/2107) - [ScenePreview] fix: 1st chunk was computing all views [PR](https://github.com/alicevision/Meshroom/pull/2108) - [bin] meshroom_batch: Save the graph once it has been all set up and resolved [PR](https://github.com/alicevision/Meshroom/pull/2095) - [nodes] HDR Fusion: Fix bracket detection [PR](https://github.com/alicevision/Meshroom/pull/2143) - [core] Preserve edges by recreating all the nodes during UID evaluation [PR](https://github.com/alicevision/Meshroom/pull/2127) - [bin] `meshroom_batch`: Fix input parsing for Windows [PR](https://github.com/alicevision/Meshroom/pull/2188) - [nodes] ImageSegmentation: increase GPU requirements [PR](https://github.com/alicevision/Meshroom/pull/2195) - [ui] ImageGallery: Disable "Visualize HDR" button after clearing images [PR](https://github.com/alicevision/Meshroom/pull/2180) - [ui] Check for the existence of the `poses` key in SfM JSON files before accessing it [PR](https://github.com/alicevision/Meshroom/pull/2190) - [nodes] CameraInit: fix tooltip focal is in mm [PR](https://github.com/alicevision/Meshroom/pull/2202) - [ui] Viewer2D: various orientation fixes [PR](https://github.com/alicevision/Meshroom/pull/2212) - [ui] ImageGallery: Use commands to set SfM attributes through the Image Gallery [PR](https://github.com/alicevision/Meshroom/pull/2220) - [ui] Preserve last `CameraInit` index when updating the CameraInits list [PR](https://github.com/alicevision/Meshroom/pull/2145) - [ui] Don't load a node's output in the 3DViewer if it has no 3D output [PR](https://github.com/alicevision/Meshroom/pull/2230) - [pipelines] Photogrammetry Draft: Add a `PrepareDenseScene` node to the template [PR](https://github.com/alicevision/Meshroom/pull/2232) - [Viewer3D] Bind the display status of the resection groups to QtAliceVision [PR](https://github.com/alicevision/Meshroom/pull/2257) - [core] Only update the running chunk to `STOPPED` when stopping computations [PR](https://github.com/alicevision/Meshroom/pull/2258) ### CI, Build and Documentation - Update build-ubuntu.sh [PR](https://github.com/alicevision/Meshroom/pull/1951) - Set `ALICEVISION_SEMANTIC_SEGMENTATION_MODEL` variable during the initialisation [PR](https://github.com/alicevision/Meshroom/pull/2090) - [build] Remove references to QmlAlembic in the build process [PR](https://github.com/alicevision/Meshroom/pull/2131) ### Contributors [almarouk](https://github.com/almarouk), [cbentejac](https://github.com/cbentejac), [demoulinv](https://github.com/demoulinv), [fabiencastan](https://github.com/fabiencastan), [gregoire-dl](https://github.com/gregoire-dl), [mugulmd](https://github.com/mugulmd), [rakhnin](https://github.com/rakhnin), [servantftechnicolor](https://github.com/servantftechnicolor) ## Meshroom 2023.2.0 (2023/06/26) Based on [AliceVision 3.1.0](https://github.com/alicevision/AliceVision/tree/v3.1.0). ### Major Features - New Photometric Stereo nodes [PR](https://github.com/alicevision/Meshroom/pull/1853) - [nodes] New CheckerboardDetection node [PR](https://github.com/alicevision/Meshroom/pull/1869) - [nodes] Split360Images: support for SfMData file input and output [PR](https://github.com/alicevision/Meshroom/pull/1939) - [sfmTransform] add auto mode [PR](https://github.com/alicevision/Meshroom/pull/1954) - [nodes] DepthMap: New option for multi-resolution similarity estimation and optimizations [PR](https://github.com/alicevision/Meshroom/pull/1984) - [nodes] Distortion calibration [PR](https://github.com/alicevision/Meshroom/pull/1986) - Add a template for the HDR fusion [PR](https://github.com/alicevision/Meshroom/pull/2032) - [pipelines] new CameraTracking pipeline [PR](https://github.com/alicevision/Meshroom/pull/2033) - [pipelines] new photogrammetry and camera tracking pipeline [PR](https://github.com/alicevision/Meshroom/pull/2041) ### Features - StructureFromMotion: Add new inputs parameters [PR](https://github.com/alicevision/Meshroom/pull/1980) - [panorama] option to build contact sheet [PR](https://github.com/alicevision/Meshroom/pull/1945) - Stitching color space [PR](https://github.com/alicevision/Meshroom/pull/1937) - Add compression option for exr and jpg images [PR](https://github.com/alicevision/Meshroom/pull/1972) - Add rec709 color space options [PR](https://github.com/alicevision/Meshroom/pull/1978) - [nodes] rewrite RenderAnimatedCamera [PR](https://github.com/alicevision/Meshroom/pull/2030) - [core] Detect and handle UID conflicts when loading a graph [PR](https://github.com/alicevision/Meshroom/pull/2059) ### Other Improvements - Start Development Version 2023.2.0 [PR](https://github.com/alicevision/Meshroom/pull/1953) - [core] Correctly parse status in version names when it exists [PR](https://github.com/alicevision/Meshroom/pull/1966) - [tests] TemplatesVersion: Add message when compatibility assertion is raised [PR](https://github.com/alicevision/Meshroom/pull/1964) - [ui] add new patterns to load images in viewer2D [PR](https://github.com/alicevision/Meshroom/pull/1975) - [nodes] KeyframeSelection: Add support for SfMData files as inputs and outputs [PR](https://github.com/alicevision/Meshroom/pull/1967) - [panorama] Panorama preview size [PR](https://github.com/alicevision/Meshroom/pull/1944) - add trackbuilder node [PR](https://github.com/alicevision/Meshroom/pull/1987) - [submitters] propagate REZ_PROD_PACKAGES_PATH environment variable [PR](https://github.com/alicevision/Meshroom/pull/1992) - HDR images naming [PR](https://github.com/alicevision/Meshroom/pull/1999) - [nodes] StructureFromMotion: new nbOutliersThreshold attribute [PR](https://github.com/alicevision/Meshroom/pull/2014) - [ui] Reflect changes made in QtAliceVision refactorize PR [PR](https://github.com/alicevision/Meshroom/pull/1924) - Exposure and format adjustment [PR](https://github.com/alicevision/Meshroom/pull/1983) - [nodes] SfMTransform: add alignGround option [PR](https://github.com/alicevision/Meshroom/pull/2020) - [nodes] ScenePreview: use base image name for naming output [PR](https://github.com/alicevision/Meshroom/pull/2035) - [nodes] KeyframeSelection: Set a dynamic size for the node [PR](https://github.com/alicevision/Meshroom/pull/2039) - KeyframeSelection: Add new parameter value to disable the export of keyframes [PR](https://github.com/alicevision/Meshroom/pull/2036) - Viewer2D: Dynamically update the list of viewable outputs [PR](https://github.com/alicevision/Meshroom/pull/2044) - [ui] ImageGallery: Display the name of the active `CameraInit` group [PR](https://github.com/alicevision/Meshroom/pull/2046) - [nodes] StereoPhotometry: Fix some labels and descriptions [PR](https://github.com/alicevision/Meshroom/pull/2034) - [ui] Display an icon on nodes that have viewable outputs [PR](https://github.com/alicevision/Meshroom/pull/2047) - [ui] Display an icon on nodes that have viewable 3D outputs [PR](https://github.com/alicevision/Meshroom/pull/2052) - [pipelines] cameraTracking: change StructureFromMotion parameters [PR](https://github.com/alicevision/Meshroom/pull/2055) - [nodes] Harmonize and improve nodes descriptions [PR](https://github.com/alicevision/Meshroom/pull/2063) - [blender] preview: use cycles render engine [PR](https://github.com/alicevision/Meshroom/pull/2064) - [blender] preview: occlusions in wireframe shading [PR](https://github.com/alicevision/Meshroom/pull/2071) ### Bugfixes, Build and Documentation - [doc] RELEASING: Add example command to generate the release note [PR](https://github.com/alicevision/Meshroom/pull/1990) - [core] Stats: Retrieve and set the GPU name if it is found [PR](https://github.com/alicevision/Meshroom/pull/1996) - [bin] Fix all the scripts that had errors [PR](https://github.com/alicevision/Meshroom/pull/1995) - [ui] ImageGallery: Reset viewpoints and intrinsics when removing all the images [PR](https://github.com/alicevision/Meshroom/pull/2031) - [nodes] CameraInit: access intrinsic properties safely [PR](https://github.com/alicevision/Meshroom/pull/2040) - [blender] preview: handle background image not found [PR](https://github.com/alicevision/Meshroom/pull/2045) - Bump requests from 2.22.0 to 2.31.0 [PR](https://github.com/alicevision/Meshroom/pull/2018) - [blender] preview: clear loaded images to avoid memory leak [PR](https://github.com/alicevision/Meshroom/pull/2053) - Fix submit through simpleFarm [PR](https://github.com/alicevision/Meshroom/pull/2054) - [ui] thumbnails: fallback if thumbnailDir could not be created [PR](https://github.com/alicevision/Meshroom/pull/2057) - [core] fix transitive reduction when submitting graph [PR](https://github.com/alicevision/Meshroom/pull/2058) - [doc] Update readme for custom pipelines and nodes [PR](https://github.com/alicevision/Meshroom/pull/2009) - [core] Include the node's type in the UID computation [PR](https://github.com/alicevision/Meshroom/pull/2038) - [doc] INSTALL: Add info about the sphere detection model [PR](https://github.com/alicevision/Meshroom/pull/2067) - [blender] preview: use Freestyle for line art shading [PR](https://github.com/alicevision/Meshroom/pull/2074) - Set `ALICEVISION_SPHERE_DETECTION_MODEL` variable during the initialisation [PR](https://github.com/alicevision/Meshroom/pull/2083) ### Contributors [almarouk](https://github.com/almarouk), [cbentejac](https://github.com/cbentejac), [demoulinv](https://github.com/demoulinv), [earlywill](https://github.com/earlywill), [erikjwaxx](https://github.com/erikjwaxx), [fabiencastan](https://github.com/fabiencastan), [Garoli](https://github.com/Garoli), [gregoire-dl](https://github.com/gregoire-dl), [ICIbrahim](https://github.com/ICIbrahim), [jmelou](https://github.com/jmelou), [mugulmd](https://github.com/mugulmd), [serguei-k](https://github.com/serguei-k), [servantftechnicolor](https://github.com/servantftechnicolor), [simogasp](https://github.com/simogasp) ## Meshroom 2023.1.0 (2023/03/22) Based on [AliceVision 3.0.0](https://github.com/alicevision/AliceVision/tree/v3.0.0). ### Release Notes Summary - Major improvements of the depth map quality, performances and scalability. The full resolution can now be computed on most of the standard GPUs. - FeatureExtraction is now using DSP-SIFT by default for the 3D Reconstruction pipeline. - Capacity to create panoramas with very high resolutions using a limited amount of memory. - Enhanced interpretation of RAW images, including new support for Adobe Digital Camera Profile and Lens Camera Profiles databases (if installed on your workstation). - Improved color management with OCIO support and more options to export in various colorspaces including ACEScg. - New graph templates enabling users to create custom pipelines. - Expose a new experimental pipeline for Camera Tracking. - Improved GraphEditor with copy-paste and multi-selection. - Improved ImageGallery with thumbnails cache and search options. - 2D Viewer is now using floating-point images by default. - And a very large amount of UI improvements and bug fixes. ### Main Features - [nodes] DepthMap: depth map improvements [PR](https://github.com/alicevision/Meshroom/pull/1818) - Integration of AprilTag library according to issue #1179 and AliceVision pull request #950 [PR](https://github.com/alicevision/Meshroom/pull/1180) - [nodes] add gps option to SfMTransform [PR](https://github.com/alicevision/Meshroom/pull/1477) - [ui] add support for selecting multiple nodes at once [PR](https://github.com/alicevision/Meshroom/pull/1227) - Image Gallery: Add a menu to set the StructureFromMotion initial pair from the gallery [PR](https://github.com/alicevision/Meshroom/pull/1936) - Texturing Color Space [PR](https://github.com/alicevision/Meshroom/pull/1933) - Add support for Lens Camera Profiles (LCP) [PR](https://github.com/alicevision/Meshroom/pull/1771) - RAW advanced processing [PR](https://github.com/alicevision/Meshroom/pull/1918) - Add new file watcher behaviours [PR](https://github.com/alicevision/Meshroom/pull/1812) - Add internal attributes in "Notes" tab [PR](https://github.com/alicevision/Meshroom/pull/1744) - New nodes for large memory use in panoramas [PR](https://github.com/alicevision/Meshroom/pull/1819) - [ui] Thumbnail cache [PR](https://github.com/alicevision/Meshroom/pull/1861) - [nodes] new SfMTriangulation node [PR](https://github.com/alicevision/Meshroom/pull/1842) - Color management for RAW images [PR](https://github.com/alicevision/Meshroom/pull/1718) - [ui] image gallery search bar [PR](https://github.com/alicevision/Meshroom/pull/1816) - [ui] Viewer 2D: enable the HDR viewer by default [PR](https://github.com/alicevision/Meshroom/pull/1793) - [ui] Improve the manipulator of the panorama viewer [PR](https://github.com/alicevision/Meshroom/pull/1707) - Color space management [PR](https://github.com/alicevision/Meshroom/pull/1792) - Show generated images in 2D viewer when double-clicking on node [PR](https://github.com/alicevision/Meshroom/pull/1776) - [ui] Elapsed time indicators in log [PR](https://github.com/alicevision/Meshroom/pull/1787) - [nodes] SfMTransform: add auto_from_cameras_x_axis [PR](https://github.com/alicevision/Meshroom/pull/1390) - Graph Editor: Support copy/paste of selected nodes and scene import [PR](https://github.com/alicevision/Meshroom/pull/1758) - [Feature Matching] Add an option to remove matches without enough motion [PR](https://github.com/alicevision/Meshroom/pull/1740) - Output in ACES or ACEScg color space [PR](https://github.com/alicevision/Meshroom/pull/1681) - Use project files to define pipelines [PR](https://github.com/alicevision/Meshroom/pull/1727) - [nodes] StructureFromMotion: Add option computeStructureColor [PR](https://github.com/alicevision/Meshroom/pull/1635) - [core] add env var to load nodes from multiple folders [PR](https://github.com/alicevision/Meshroom/pull/1616) - Depth map refactoring [PR](https://github.com/alicevision/Meshroom/pull/680) - Draft Reconstruction pipeline [PR](https://github.com/alicevision/Meshroom/pull/1489) - [ui] Add filters to image gallery [PR](https://github.com/alicevision/Meshroom/pull/1500) - [nodes] New node "RenderAnimatedCamera" using blender API [PR](https://github.com/alicevision/Meshroom/pull/1432) - New node to import known poses for various file formats [PR](https://github.com/alicevision/Meshroom/pull/1475) - New ImageMasking and MeshMasking nodes [PR](https://github.com/alicevision/Meshroom/pull/1483) - Create Split360Images Node [PR](https://github.com/alicevision/Meshroom/pull/1464) - New lens distortion calibration node [PR](https://github.com/alicevision/Meshroom/pull/1403) - New experimental camera tracking pipeline [PR](https://github.com/alicevision/Meshroom/pull/1379) - [multiview] New pipeline "Photogrammetry and Camera Tracking" [PR](https://github.com/alicevision/Meshroom/pull/1429) - [nodes] KeyframeSelection: Rework the node and add parameters for new selection methods [PR](https://github.com/alicevision/Meshroom/pull/1880) ### Other Improvements - [nodes] ImageProcessing: Add and hide the fringing correction in the LCP [PR](https://github.com/alicevision/Meshroom/pull/1930) - Update highlight mode description in imageProcessing node [PR](https://github.com/alicevision/Meshroom/pull/1928) - [ui] Prompt a warning dialog when attempting to submit an unsaved project [PR](https://github.com/alicevision/Meshroom/pull/1927) - [panorama] force pyramid levels count in compositing [PR](https://github.com/alicevision/Meshroom/pull/1919) - [ui] Add a new advanced menu action to load templates like regular projects [PR](https://github.com/alicevision/Meshroom/pull/1920) - [panorama] New option to disable compositing tiling [PR](https://github.com/alicevision/Meshroom/pull/1916) - [sfmtransform] Transformation parameter availability [PR](https://github.com/alicevision/Meshroom/pull/1876) - Apply DCP metadata in imageProcessing [PR](https://github.com/alicevision/Meshroom/pull/1879) - [ui] FeaturesViewer: track endpoints [PR](https://github.com/alicevision/Meshroom/pull/1838) - LdrToHdrMerge node: Add a checkbox enabling the manual setting of the reference bracket for HDR merging [PR](https://github.com/alicevision/Meshroom/pull/1849) - [ui] Display nodes computed in another Meshroom instance as "Computed Externally" [PR](https://github.com/alicevision/Meshroom/pull/1862) - [ui] Use the location of the most recently imported images as the base folder for the "Import Images" dialog [PR](https://github.com/alicevision/Meshroom/pull/1864) - [ui] GraphEditor: use maxZoom to fit on nodes [PR](https://github.com/alicevision/Meshroom/pull/1865) - [ui] Viewer2D: support all Exif orientation tags [PR](https://github.com/alicevision/Meshroom/pull/1857) - Use DCP by default if the database is set and create errors on missing DCP files [PR](https://github.com/alicevision/Meshroom/pull/1863) - [ui] Load 3D Depth Map: minor improvements [PR](https://github.com/alicevision/Meshroom/pull/1852) - [ui] Checkbox to enable/disable 8-bit viewer [PR](https://github.com/alicevision/Meshroom/pull/1858) - Add Ripple submitter [PR](https://github.com/alicevision/Meshroom/pull/1844) - [ui] ImageGallery: Increase the GridView's cache capacity [PR](https://github.com/alicevision/Meshroom/pull/1855) - [ui] Reorganize the "File" menu [PR](https://github.com/alicevision/Meshroom/pull/1856) - [nodes] rename: remove "utils" from executables names [PR](https://github.com/alicevision/Meshroom/pull/1848) - [ui] Integrate QtOIIO into QtAliceVision [PR](https://github.com/alicevision/Meshroom/pull/1831) - Add nl means denoising open cv in image processing node [PR](https://github.com/alicevision/Meshroom/pull/1719) - [core] Add cgroups support to meshroom [PR](https://github.com/alicevision/Meshroom/pull/1836) - Remove support for Python 2 [PR](https://github.com/alicevision/Meshroom/pull/1837) - [submitters] Add an option to update the job title on submitters [PR](https://github.com/alicevision/Meshroom/pull/1824) - [ui] GraphEditor: create new pipelines with the node menu [PR](https://github.com/alicevision/Meshroom/pull/1833) - [bin] meshroom_batch: allow passing list of values to param overrides [PR](https://github.com/alicevision/Meshroom/pull/1811) - [ui] ImageGallery: update the Viewer2D correctly when the GridView's current item changes [PR](https://github.com/alicevision/Meshroom/pull/1823) - [ui] keyboard shortcut: press tab to open node menu [PR](https://github.com/alicevision/Meshroom/pull/1813) - Update bounding box display to use the correct geometric frame [PR](https://github.com/alicevision/Meshroom/pull/1805) - [ui] Paste nodes at the center of the Graph Editor when it does not contain the mouse [PR](https://github.com/alicevision/Meshroom/pull/1788) - Use most recent project as base folder for file dialogs [PR](https://github.com/alicevision/Meshroom/pull/1778) - [ui] Restrain the "copy/paste nodes" shortcuts to the GraphEditor [PR](https://github.com/alicevision/Meshroom/pull/1782) - [core] Set the "template" flag to "false" when saving a project as a regular file [PR](https://github.com/alicevision/Meshroom/pull/1777) - [ui] Display computation time for "running" or "finished" nodes [PR](https://github.com/alicevision/Meshroom/pull/1764) - Removed duplicated call to findnodes [PR](https://github.com/alicevision/Meshroom/pull/1767) - Add dedicated "minimal" mode for templates [PR](https://github.com/alicevision/Meshroom/pull/1754) - [ui] Reduce confusion when qml loading fails [PR](https://github.com/alicevision/Meshroom/pull/1728) - [ui] Update intrinsics table when switching between groups [PR](https://github.com/alicevision/Meshroom/pull/1755) - [bin] batch: allow to set params inside groups [PR](https://github.com/alicevision/Meshroom/pull/1665) - [camerainit] update parameters to use focal in mm [PR](https://github.com/alicevision/Meshroom/pull/1652) - [bin] newNodeType: update [PR](https://github.com/alicevision/Meshroom/pull/1630) - [minor] renderfarm submission with rez [PR](https://github.com/alicevision/Meshroom/pull/1629) - [ui] widgets visibility options [PR](https://github.com/alicevision/Meshroom/pull/1545) - [bin] Avoid multi-threading in non-interactive computation [PR](https://github.com/alicevision/Meshroom/pull/1553) - [nodes] Mesh*: use file extension to choose the file format [PR](https://github.com/alicevision/Meshroom/pull/1524) - Upgrade Texturing node and add multiples mesh file types [PR](https://github.com/alicevision/Meshroom/pull/1508) - Optical center relative to the image center [PR](https://github.com/alicevision/Meshroom/pull/1509) - [core] Improve project files upgrade [PR](https://github.com/alicevision/Meshroom/pull/1503) - [ui] Add a clear images button [PR](https://github.com/alicevision/Meshroom/pull/1467) - [ui] highlight the edge that will be deleted [PR](https://github.com/alicevision/Meshroom/pull/1434) - Update 2d viewer for new Track drawing mode of QtAliceVision [PR](https://github.com/alicevision/Meshroom/pull/1435) - Add cli script to start Meshroom on Windows [PR](https://github.com/alicevision/Meshroom/pull/1169) - Allow replacing edges [PR](https://github.com/alicevision/Meshroom/pull/1355) - No cmd line range arguments if we have only a single chunk [PR](https://github.com/alicevision/Meshroom/pull/1426) - [nodes] ExportAnimatedCameras: new sfmDataFilter parameter [PR](https://github.com/alicevision/Meshroom/pull/1428) - Node highlight radius [PR](https://github.com/alicevision/Meshroom/pull/1357) ### Bug Fixes, Build and Documentation - [ui] Fix conditions on which the prompt asking the user to save a project before submitting it to the render farm relies [PR](https://github.com/alicevision/Meshroom/pull/1942) - [ui] ImageGallery: Allow image drop if the active group is not computing [PR](https://github.com/alicevision/Meshroom/pull/1941) - [ui] Viewer2D: fix displayed metadata [PR](https://github.com/alicevision/Meshroom/pull/1915) - [setup] add all scripts in bin/ as executables [PR](https://github.com/alicevision/Meshroom/pull/1419) - Add a unit test to check the node versions of templates [PR](https://github.com/alicevision/Meshroom/pull/1799) - [nodes] Split360Images: update attributes to software version 2.0 [PR](https://github.com/alicevision/Meshroom/pull/1935) - [ci] upgrade github actions rules [PR](https://github.com/alicevision/Meshroom/pull/1834) - Update INSTALL.md [PR](https://github.com/alicevision/Meshroom/pull/1803) - [docs] Python documentation generation using Sphinx [PR](https://github.com/alicevision/Meshroom/pull/1794) - Documentation update : how to use Meshroom without building AliceVision [PR](https://github.com/alicevision/Meshroom/pull/1487) - [pipelines] Panorama: Fix inputs of the "Publish" nodes [PR](https://github.com/alicevision/Meshroom/pull/1922) - [nodes] ExportAnimatedCameras: fix output params labels [PR](https://github.com/alicevision/Meshroom/pull/1911) - [nodes] PanoramaWarping: remove obsolete image output attributes [PR](https://github.com/alicevision/Meshroom/pull/1914) - Fix the documentation related to Panorama nodes [PR](https://github.com/alicevision/Meshroom/pull/1917) - Fix missing Publish nodes in templates [PR](https://github.com/alicevision/Meshroom/pull/1903) - [ui] Intrinsics: Fix warnings and exceptions [PR](https://github.com/alicevision/Meshroom/pull/1898) - [ui] fix thumbnail cache bugs [PR](https://github.com/alicevision/Meshroom/pull/1893) - [ImageGallery] Match the filter selection with the gallery's display [PR](https://github.com/alicevision/Meshroom/pull/1899) - [ui] fix "Sync Camera with Image Selection" [PR](https://github.com/alicevision/Meshroom/pull/1888) - Fix exceptions raised when accessing attributes that either do not exist or are not associated to a graph [PR](https://github.com/alicevision/Meshroom/pull/1889) - fix(sec): upgrade psutil to 5.6.7 [PR](https://github.com/alicevision/Meshroom/pull/1843) - [ui] Fix all "TypeError" QML warnings [PR](https://github.com/alicevision/Meshroom/pull/1839) - [ui] Viewer2D: fix minor issues [PR](https://github.com/alicevision/Meshroom/pull/1829) - Fix crash when importing images with non-ascii characters in their filepath [PR](https://github.com/alicevision/Meshroom/pull/1809) - Fix and prevent mismatches between an attribute's type and its default value's type [PR](https://github.com/alicevision/Meshroom/pull/1784) - Fix various typos [PR](https://github.com/alicevision/Meshroom/pull/1768) - [ui] ImageGallery: fix some minor issues [PR](https://github.com/alicevision/Meshroom/pull/1766) - [core] fix logging of nodes loading [PR](https://github.com/alicevision/Meshroom/pull/1748) - Fix node duplication/removal behaviour [PR](https://github.com/alicevision/Meshroom/pull/1738) - [ui] Fix offset between the mouse's position and the tip of the edge when connecting two nodes [PR](https://github.com/alicevision/Meshroom/pull/1732) - Fix compatibility with Python 3 [PR](https://github.com/alicevision/Meshroom/pull/1734) - Fix stats [PR](https://github.com/alicevision/Meshroom/pull/1704) - [ui] ImageGallery: fix missing function changeCurrentIndex [PR](https://github.com/alicevision/Meshroom/pull/1679) - [UI] StatViewer: fix displayed unit [PR](https://github.com/alicevision/Meshroom/pull/1547) - [ui] fix uvCenterOffset [PR](https://github.com/alicevision/Meshroom/pull/1551) - Fix meshroom_batch [PR](https://github.com/alicevision/Meshroom/pull/1521) - Fix incompatibility with recent cx_Freeze [PR](https://github.com/alicevision/Meshroom/pull/1480) - [bin] meshroom_batch: fix typo in pipeline names [PR](https://github.com/alicevision/Meshroom/pull/1377) - Removing `io_counters` from the ProcStatatistics [PR](https://github.com/alicevision/Meshroom/pull/1374) - Fix NameError [PR](https://github.com/alicevision/Meshroom/pull/1312) - [ui] Image Gallery: Fix the display of the intrinsics table with temporary CameraInit nodes [PR](https://github.com/alicevision/Meshroom/pull/1934) - [ui] Correctly update the Viewer 2D when there are temporary CameraInit nodes [PR](https://github.com/alicevision/Meshroom/pull/1931) - [ui] Clear Images: Request a graph update after resetting the viewpoints and intrinsics [PR](https://github.com/alicevision/Meshroom/pull/1929) - [ui] Improve "Clear Images" action's behaviour and performance [PR](https://github.com/alicevision/Meshroom/pull/1897) - [Viewer] Load and unload the SfMStats components explicitly every time they are shown and hidden [PR](https://github.com/alicevision/Meshroom/pull/1912) - [ui] Drag&Drop: Use a pool of threads for asynchronous intrinsics computations [PR](https://github.com/alicevision/Meshroom/pull/1896) - [nodes] CameraInit: upgrade version following the parameters changes [PR](https://github.com/alicevision/Meshroom/pull/1874) - [ui] app: temporary workaround for qInstallMessageHandler [PR](https://github.com/alicevision/Meshroom/pull/1873) - [ui] ImageGallery: fix the DB path in the "Edit Sensor Database" dialog [PR](https://github.com/alicevision/Meshroom/pull/1860) - [ui] Correctly determine if a graph is being computed locally and update nodes' statuses accordingly [PR](https://github.com/alicevision/Meshroom/pull/1832) - [nodes] CameraInit: all intrinsics parameters should invalidate [PR](https://github.com/alicevision/Meshroom/pull/1747) - [ci] add bug to the list of tag to skip the stale check [PR](https://github.com/alicevision/Meshroom/pull/1745) - Fix various typos in the source code [PR](https://github.com/alicevision/Meshroom/pull/1606) - Update ion startup [PR](https://github.com/alicevision/Meshroom/pull/1815) - New script to launch meshroom under ion environment [PR](https://github.com/alicevision/Meshroom/pull/1783) - [doc] fix the bibtex [PR](https://github.com/alicevision/Meshroom/pull/1537) - [doc] readme: add citation [PR](https://github.com/alicevision/Meshroom/pull/1520) ### Contributors Thanks to [Fabien Servant](https://github.com/servantftechnicolor), [Gregoire De Lillo](https://github.com/gregoire-dl), [Vincent Demoulin](https://github.com/demoulinv), [Thomas Zorroche](https://github.com/Thomas-Zorroche), [Povilas Kanapickas](https://github.com/p12tic), [Simone Gasparini](https://github.com/simogasp), [Candice Bentejac](https://github.com/cbentejac), [Loic Vital](https://github.com/mugulmd), [Charles Johnson](https://github.com/ChemicalXandco), [Jean Melou](https://github.com/jmelou), [Matthieu Hog](https://github.com/mh0g), [Simon Schuette](https://github.com/natowi), [Ludwig Chieng](https://github.com/ludchieng), [Vincent Scavinner](https://github.com/vscav), [Nils Landrodie](https://github.com/N0Ls), [Stella Tan](https://github.com/tanstella) for the major contributions. Other release contributors: [asoftbird](https://github.com/asoftbird), [DanielDelaporus](https://github.com/DanielDelaporus), [DataBeaver](https://github.com/DataBeaver), [elektrokokke](https://github.com/elektrokokke), [fabiencastan](https://github.com/fabiencastan), [Garoli](https://github.com/Garoli), [ghost](https://github.com/ghost), [hammady](https://github.com/hammady), [luzpaz](https://github.com/luzpaz), [MakersF](https://github.com/MakersF), [pen4](https://github.com/pen4), [remmel](https://github.com/remmel), [wolfgangp](https://github.com/wolfgangp) ## Release 2021.1.0 (2021/02/26) Based on [AliceVision 2.4.0](https://github.com/alicevision/AliceVision/tree/v2.4.0). ### Release Notes Summary - [panorama] PanoramaCompositing: new algorithm with tiles to deal with large panoramas [PR](https://github.com/alicevision/meshroom/pull/1173) - [feature] Improve robustness of sift features extraction on challenging images: update default values, add new filtering and add dsp-sift variation [PR](https://github.com/alicevision/meshroom/pull/1164) - [ui] Improve Graph Editor UX with better visualization of nodes connections, the ability to accumulate nodes to compute locally or the ability to compute multiple branches in parallel on renderfarm with a new locking system per node, etc. [PR](https://github.com/alicevision/meshroom/pull/612) - [nodes] Meshing: improve mesh quality with a new post-processing. Cells empty/full status are filtered by solid angle ratio to favor smoothness. [PR](https://github.com/alicevision/meshroom/pull/1274) - [nodes] MeshFiltering: smoothing & filtering on subset of the geometry [PR](https://github.com/alicevision/meshroom/pull/1272) - [ui] Viewer: fix gain/gamma behavior and use non-linear sliders [PR](https://github.com/alicevision/meshroom/pull/1092) ### Other Improvements and Bug Fixes - [core] taskManager: downgrade status per chunk [PR](https://github.com/alicevision/meshroom/pull/1210) - [core] Improve graph dependencies: dependencies to an input parameter is not a real dependency [PR](https://github.com/alicevision/meshroom/pull/1182) - [nodes] Meshing: Add `addMaskHelperPoints` option [PR](https://github.com/alicevision/meshroom/pull/1273) - [nodes] Meshing: More control on graph cut post processing [PR](https://github.com/alicevision/meshroom/pull/1284) - [nodes] Meshing: new cells filtering by solid angle ratio [PR](https://github.com/alicevision/meshroom/pull/1274) - [nodes] Meshing: add seed and voteFilteringForWeaklySupportedSurfaces [PR](https://github.com/alicevision/meshroom/pull/1268) - [nodes] Add some mesh utilities nodes [PR](https://github.com/alicevision/meshroom/pull/1271) - [nodes] SfmTransform: new from_center_camera [PR](https://github.com/alicevision/meshroom/pull/1281) - [nodes] Panorama: new options to init with known poses [PR](https://github.com/alicevision/meshroom/pull/1230) - [nodes] FeatureMatching: add cross verification [PR](https://github.com/alicevision/meshroom/pull/1276) - [nodes] ExportAnimatedCamera: New option to export undistort maps in EXR format [PR](https://github.com/alicevision/meshroom/pull/1229) - [nodes] new wip node `LightingEstimation` to estimate spherical harmonics from normal map and albedo [PR](https://github.com/alicevision/meshroom/pull/390) - [nodes] CameraInit: add a boolean for white balance use [PR](https://github.com/alicevision/meshroom/pull/1162) - [ui] fix error on live reconstruction [PR](https://github.com/alicevision/meshroom/pull/1145) - [ui] init saveAs folder [PR](https://github.com/alicevision/meshroom/pull/1099) - [ui] add link to online documentation in 'Help' menu [PR](https://github.com/alicevision/meshroom/pull/1279) - [ui] New node menu categories [PR](https://github.com/alicevision/meshroom/pull/1278) ## Release 2020.1.1 (2020/10/14) Based on [AliceVision 2.3.1](https://github.com/alicevision/AliceVision/tree/v2.3.1). - [core] Fix crashes on process statistics (windows-only) [PR](https://github.com/alicevision/meshroom/pull/1096) ## Release 2020.1.0 (2020/10/09) Based on [AliceVision 2.3.0](https://github.com/alicevision/AliceVision/tree/v2.3.0). ### Release Notes Summary - [nodes] New Panorama Stitching nodes with support for fisheye lenses [PR](https://github.com/alicevision/meshroom/pull/639) [PR](https://github.com/alicevision/meshroom/pull/808) - [nodes] HDR: Largely improved HDR calibration, including new LdrToHdrSampling for optimal sample selection [PR](https://github.com/alicevision/meshroom/pull/808) [PR](https://github.com/alicevision/meshroom/pull/1016) [PR](https://github.com/alicevision/meshroom/pull/990) - [ui] Viewer3D: Input bounding box (Meshing) & manual transformation (SfMTransform) thanks to a new 3D Gizmo [PR](https://github.com/alicevision/meshroom/pull/978) - [ui] Sync 3D camera with image selection [PR](https://github.com/alicevision/meshroom/pull/633) - [ui] New HDR (floating point) Image Viewer [PR](https://github.com/alicevision/meshroom/pull/795) - [ui] Ability to load depth maps into 2D and 3D Viewers [PR](https://github.com/alicevision/meshroom/pull/769) [PR](https://github.com/alicevision/meshroom/pull/657) - [ui] New features overlay in Viewer2D allows to display tracks and landmarks [PR](https://github.com/alicevision/meshroom/pull/873) [PR](https://github.com/alicevision/meshroom/pull/1001) - [ui] Add SfM statistics [PR](https://github.com/alicevision/meshroom/pull/873) - [ui] Visual interface for node resources usage [PR](https://github.com/alicevision/meshroom/pull/564) - [nodes] Coordinate system alignment to specific markers or between scenes [PR](https://github.com/alicevision/meshroom/pull/652) - [nodes] New Sketchfab upload node [PR](https://github.com/alicevision/meshroom/pull/712) - [ui] Dynamic Parameters: add a new 'enabled' property to node's attributes [PR](https://github.com/alicevision/meshroom/pull/1007) [PR](https://github.com/alicevision/meshroom/pull/1027) - [ui] Viewer: add Camera Response Function display [PR](https://github.com/alicevision/meshroom/pull/1020) [PR](https://github.com/alicevision/meshroom/pull/1041) - [ui] UI improvements in the Viewer2D and ImageGallery [PR](https://github.com/alicevision/meshroom/pull/823) - [bin] Improve Meshroom command line [PR](https://github.com/alicevision/meshroom/pull/759) [PR](https://github.com/alicevision/meshroom/pull/632) - [nodes] New ImageProcessing node [PR](https://github.com/alicevision/meshroom/pull/839) [PR](https://github.com/alicevision/meshroom/pull/970) [PR](https://github.com/alicevision/meshroom/pull/941) - [nodes] `FeatureMatching` Add `fundamental_with_distortion` option [PR](https://github.com/alicevision/meshroom/pull/931) - [multiview] Declare more recognized image file extensions [PR](https://github.com/alicevision/meshroom/pull/965) - [multiview] More generic metadata support [PR](https://github.com/alicevision/meshroom/pull/957) ### Other Improvements and Bug Fixes - [nodes] CameraInit: New viewId generation and selection of allowed intrinsics [PR](https://github.com/alicevision/meshroom/pull/973) - [core] Avoid error during project load on border cases [PR](https://github.com/alicevision/meshroom/pull/991) - [core] Compatibility : Improve list of groups update [PR](https://github.com/alicevision/meshroom/pull/791) - [core] Invalidation hooks [PR](https://github.com/alicevision/meshroom/pull/732) - [core] Log manager for Python based nodes [PR](https://github.com/alicevision/meshroom/pull/631) - [core] new Node Update Hooks mechanism [PR](https://github.com/alicevision/meshroom/pull/733) - [core] Option to make chunks optional [PR](https://github.com/alicevision/meshroom/pull/778) - [nodes] Add methods in ImageMatching and features in StructureFromMotion and FeatureMatching [PR](https://github.com/alicevision/meshroom/pull/768) - [nodes] FeatureExtraction: add maxThreads argument [PR](https://github.com/alicevision/meshroom/pull/647) - [nodes] Fix python nodes being blocked by log [PR](https://github.com/alicevision/meshroom/pull/783) - [nodes] ImageProcessing: add new option to fix non finite pixels [PR](https://github.com/alicevision/meshroom/pull/1057) - [nodes] Meshing: simplify input depth map folders [PR](https://github.com/alicevision/meshroom/pull/951) - [nodes] PanoramaCompositing: add a new graphcut option to improve seams [PR](https://github.com/alicevision/meshroom/pull/1026) - [nodes] PanoramaCompositing: option to select the percentage of upscaled pixels [PR](https://github.com/alicevision/meshroom/pull/1049) - [nodes] PanoramaInit: add debug circle detection option [PR](https://github.com/alicevision/meshroom/pull/1069) - [nodes] PanoramaInit: New parameter to set an extra image rotation to each camera declared the input xml [PR](https://github.com/alicevision/meshroom/pull/1046) - [nodes] SfmTransfer: New option to transfer intrinsics parameters [PR](https://github.com/alicevision/meshroom/pull/1053) - [nodes] StructureFromMotion: Add features’s scale as an option [PR](https://github.com/alicevision/meshroom/pull/822) [PR](https://github.com/alicevision/meshroom/pull/817) - [nodes] Texturing: add options for retopoMesh & reorganise options [PR](https://github.com/alicevision/meshroom/pull/571) - [nodes] Texturing: put downscale to 2 by default [PR](https://github.com/alicevision/meshroom/pull/1048) - [sfm] Add option to include 'unknown' feature types in ConvertSfMFormat, needed to be used on dense point cloud from the Meshing node [PR](https://github.com/alicevision/meshroom/pull/584) - [ui] Automatically update layout when needed [PR](https://github.com/alicevision/meshroom/pull/989) - [ui] Avoid crash in 3D with large panoramas [PR](https://github.com/alicevision/meshroom/pull/1061) - [ui] Fix graph axes naming for ram statistics [PR](https://github.com/alicevision/meshroom/pull/1033) - [ui] NodeEditor: minor improvements with single tab group and status table [PR](https://github.com/alicevision/meshroom/pull/637) - [ui] Viewer3D: Display equirectangular images as environment maps [PR](https://github.com/alicevision/meshroom/pull/731) - [windows] Fix open recent broken on windows and remove unnecessary warnings [PR](https://github.com/alicevision/meshroom/pull/940) ### Build, CI, Documentation - [build] Fix cxFreeze version for Python 2.7 compatibility [PR](https://github.com/alicevision/meshroom/pull/634) - [ci] Add github Actions [PR](https://github.com/alicevision/meshroom/pull/1051) - [ci] AppVeyor: Update build environment and save artifacts [PR](https://github.com/alicevision/meshroom/pull/875) - [ci] Travis: Update environment, remove Python 2.7 & add 3.8 [PR](https://github.com/alicevision/meshroom/pull/874) - [docker] Clean Dockerfiles [PR](https://github.com/alicevision/meshroom/pull/1054) - [docker] Move to PySide2 / Qt 5.14.1 - [docker] Fix some packaging issues of the release 2019.2.0 [PR](https://github.com/alicevision/meshroom/pull/627) - [github] Add exemptLabels [PR](https://github.com/alicevision/meshroom/pull/801) - [github] Add issue templates [PR](https://github.com/alicevision/meshroom/pull/579) - [github] Add template for questions / help only [PR](https://github.com/alicevision/meshroom/pull/629) - [github] Added automatic stale detection and closing for issues [PR](https://github.com/alicevision/meshroom/pull/598) - [python] Import ABC from collections.abc [PR](https://github.com/alicevision/meshroom/pull/983) For more details see all PR merged: https://github.com/alicevision/meshroom/milestone/10 See [AliceVision 2.3.0 Release Notes](https://github.com/alicevision/AliceVision/blob/v2.3.0/CHANGES.md) for more details about algorithmic changes. ## Release 2019.2.0 (2019/08/08) Based on [AliceVision 2.2.0](https://github.com/alicevision/AliceVision/tree/v2.2.0). Release Notes Summary: - Visualisation: New visualization module of the features extraction. [PR](https://github.com/alicevision/meshroom/pull/539), [New QtAliceVision](https://github.com/alicevision/QtAliceVision) - Support for RAW image files. - Texturing: Largely improve the Texturing quality. - Texturing: Speed improvements. - Texturing: Add support for UDIM. - Meshing: Export the dense point cloud in Alembic. - Meshing: New option to export the full raw dense point cloud (with all 3D points candidates before cut and filtering). - Meshing: Adds an option to export color data per vertex and MeshFiltering correctly preserves colors. Full Release Notes: - Move to PySide2 / Qt 5.13 - SfMDataIO: Change root nodes (XForms instead of untyped objects) of Alembic SfMData for better interoperability with other 3D graphics applications (in particular Blender and Houdini). - Improve performance of log display and node status update. [PR](https://github.com/alicevision/meshroom/pull/466) [PR](https://github.com/alicevision/meshroom/pull/548) - Viewer3D: Add support for vertex-colored meshes. [PR](https://github.com/alicevision/meshroom/pull/550) - New pipeline input for meshroom_photogrammetry command line and minor fixes to the input arguments. [PR](https://github.com/alicevision/meshroom/pull/567) [PR](https://github.com/alicevision/meshroom/pull/577) - New arguments to meshroom. [PR](https://github.com/alicevision/meshroom/pull/413) - HDR: New HDR module for the fusion of multiple LDR images. - PrepareDenseScene: Add experimental option to correct Exposure Values (EV) of input images to uniformize dataset exposures. - FeatureExtraction: Include CCTag in the release binaries both on Linux and Windows. - ConvertSfMFormat: Enable to use simple regular expressions in the image white list of the ConvertSfMFormat. This enables to filter out cameras based on their filename. For more details see all PR merged: https://github.com/alicevision/meshroom/milestone/9 See [AliceVision 2.2.0 Release Notes](https://github.com/alicevision/AliceVision/blob/v2.2.0/CHANGES.md) for more details about algorithmic changes. ## Release 2019.1.0 (2019/02/27) Based on [AliceVision 2.1.0](https://github.com/alicevision/AliceVision/tree/v2.1.0). Release Notes Summary: - 3D Viewer: Load and compare multiple assets with cache mechanism and improved navigation - Display camera intrinsic information extracted from metadata analysis - Easier access to a more complete sensor database with a more reliable camera model matching algorithm. - Attribute Editor: Hide advanced/experimental parameters by default to improve readability and simplify access to the most useful, high-level settings. Advanced users can still enable them to have full access to internal thresholds. - Graph Editor: Improved set of contextual tools with `duplicate`/`remove`/`delete data` actions with `From Here` option. - Nodes: Homogenization of inputs / outputs parameters - Meshing: Better, faster and configurable estimation of the space to reconstruct based on the sparse point cloud (new option `estimateSpaceFromSfM`). Favors high-density areas and helps removing badly defined ones. - Draft Meshing (no CUDA required): the result of the sparse reconstruction can now be directly meshed to get a 3D model preview without computing the depth maps. - MeshFiltering: Now keeps all reconstructed parts by default. - StructureFromMotion: Add support for rig of cameras - Support for reconstruction with projected light patterns and texturing with another set of images Full Release Notes: - Viewer3D: New Trackball camera manipulator for improved navigation in the scene - Viewer3D: New library system to load multiple 3D objects of the same type simultaneously, simplifying results comparisons - Viewer3D: Add media loading overlay with BusyIndicator - Viewer3D: Points and cameras size are now configurable via dedicated sliders. - CameraInit: Add option to lock specific cameras intrinsics (if you have high-quality internal calibration information) - StructureFromMotion: Triangulate points if the input scene contains valid camera poses and intrinsics without landmarks - PrepareDenseScene: New `imagesFolders` option to override input images. This enables to use images with light patterns projected for SfM and MVS parts and do the Texturing with another set of images. - NodeLog: Cross-platform monospace display - Remove `CameraConnection` and `ExportUndistortedImages` nodes - Multi-machine parallelization of `PrepareDenseScene` - Meshing: Add option `estimateSpaceFromSfM` and observation angles check to better estimate the bounding box of the reconstruction and avoid useless reconstruction of the environment - Console: Filter non silenced, inoffensive warnings from QML + log Qt messages via Python logging - Command line (meshroom_photogrammetry): Add --pipeline parameter to use a pre-configured pipeline graph - Command line (meshroom_photogrammetry): Add possibility to provide pre-calibrated intrinsics. - Command line (meshroom_compute): Provide `meshroom_compute` executable in packaged release. - Image Gallery: Display Camera Intrinsics initialization status with detailed explanation, edit Sensor Database dialog, advanced menu to display view UIDs - StructureFromMotion: Expose advanced estimator parameters - FeatureMatching: Expose advanced estimator parameters - DepthMap: New option `exportIntermediateResults` disabled by default, so less data storage by default than before. - DepthMap: Use multiple GPUs by default if available and add `nbGPUs` param to limit it - Meshing: Add option `addLandmarksToTheDensePointCloud` - SfMTransform: New option to align on one specific camera - Graph Editor: Consistent read-only mode when computing, that can be unlocked in advanced settings - Graph Editor: Improved Node Menu: "duplicate"/"remove"/"delete data" with "From Here" accessible on the same entry via an additional button - Graph Editor: Confirmation popup before deleting node data - Graph Editor: Add "Clear Pending Status" action at Graph level - Graph Editor: Solo media in 3D viewer with Ctrl + double click on node/attribute - Param Editor: Fix several bugs related to attributes edition - Scene Compatibility: Improves detection of deeper compatibility issues, by adding an additional recursive (taking List/GroupAttributes children into account) exact description matching test when de-serializing a Node. See [AliceVision 2.1.0 Release Notes](https://github.com/alicevision/AliceVision/blob/v2.1.0/CHANGES.md) for more details about algorithmic changes. ## Release 2018.1.0 (2018.08.09) First release of Meshroom. Based on [AliceVision 2.0.0](https://github.com/alicevision/AliceVision/tree/v2.0.0). ================================================ FILE: CMakeLists.txt ================================================ cmake_minimum_required(VERSION 3.12) project(meshroom LANGUAGES C CXX) if(NOT CMAKE_BUILD_TYPE) set(CMAKE_BUILD_TYPE Release CACHE STRING "Build type for Meshroom plugins" FORCE) endif() set(ALICEVISION_ROOT "$ENV{ALICEVISION_ROOT}" CACHE STRING "AliceVision root dir") set(QT_DIR "$ENV{QT_DIR}" CACHE STRING "Qt root directory") option(MR_BUILD_QTALICEVISION "Enable building of QtAliceVision plugin" ON) if(CMAKE_BUILD_TYPE MATCHES Release) message(STATUS "Force CMAKE_INSTALL_DO_STRIP in Release") set(CMAKE_INSTALL_DO_STRIP ON) else() set(CMAKE_INSTALL_DO_STRIP OFF) endif() set(CMAKE_CORE_BUILD_FLAGS -DCMAKE_BUILD_TYPE=${CMAKE_BUILD_TYPE} -DBUILD_SHARED_LIBS:BOOL=ON -DCMAKE_INSTALL_DO_STRIP=${CMAKE_INSTALL_DO_STRIP}) set(ALEMBIC_CMAKE_FLAGS -DAlembic_DIR:PATH=${ALICEVISION_ROOT}/lib/cmake/Alembic -DImath_DIR=${ALICEVISION_ROOT}/lib/cmake/Imath ) include(ExternalProject) # ============================================================================== # GNUInstallDirs CMake module # - Define GNU standard installation directories # - Provides install directory variables as defined by the GNU Coding Standards. # ============================================================================== include(GNUInstallDirs) # message(STATUS "QT_CMAKE_FLAGS: ${QT_CMAKE_FLAGS}") if(MR_BUILD_QTALICEVISION) set(QTALICEVISION_TARGET QtAliceVision) ExternalProject_Add(${QTALICEVISION_TARGET} GIT_REPOSITORY https://github.com/alicevision/QtAliceVision GIT_TAG develop PREFIX ${BUILD_DIR} BUILD_IN_SOURCE 0 BUILD_ALWAYS 0 SOURCE_DIR ${CMAKE_CURRENT_BINARY_DIR}/QtAliceVision BINARY_DIR ${BUILD_DIR}/QtAliceVision_build INSTALL_DIR ${CMAKE_INSTALL_PREFIX} CONFIGURE_COMMAND ${CMAKE_COMMAND} ${CMAKE_CORE_BUILD_FLAGS} ${ALEMBIC_CMAKE_FLAGS} -DCMAKE_PREFIX_PATH:PATH=${QT_DIR}$${ALICEVISION_ROOT} -DCMAKE_INSTALL_PREFIX:PATH= ) endif() ================================================ FILE: CODE_OF_CONDUCT.md ================================================ # Contributor Covenant Code of Conduct ## Our Pledge In the interest of fostering an open and welcoming environment, we as contributors and maintainers pledge to making participation in our project and our community a harassment-free experience for everyone, regardless of age, body size, disability, ethnicity, gender identity and expression, level of experience, nationality, personal appearance, race, religion, or sexual identity and orientation. ## Our Standards Examples of behavior that contributes to creating a positive environment include: * Using welcoming and inclusive language * Being respectful of differing viewpoints and experiences * Gracefully accepting constructive criticism * Focusing on what is best for the community * Showing empathy towards other community members Examples of unacceptable behavior by participants include: * The use of sexualized language or imagery and unwelcome sexual attention or advances * Trolling, insulting/derogatory comments, and personal or political attacks * Public or private harassment * Publishing others' private information, such as a physical or electronic address, without explicit permission * Other conduct which could reasonably be considered inappropriate in a professional setting ## Our Responsibilities Project maintainers are responsible for clarifying the standards of acceptable behavior and are expected to take appropriate and fair corrective action in response to any instances of unacceptable behavior. Project maintainers have the right and responsibility to remove, edit, or reject comments, commits, code, wiki edits, issues, and other contributions that are not aligned to this Code of Conduct, or to ban temporarily or permanently any contributor for other behaviors that they deem inappropriate, threatening, offensive, or harmful. ## Scope This Code of Conduct applies both within project spaces and in public spaces when an individual is representing the project or its community. Examples of representing a project or community include using an official project e-mail address, posting via an official social media account, or acting as an appointed representative at an online or offline event. Representation of a project may be further defined and clarified by project maintainers. ## Enforcement Instances of abusive, harassing, or otherwise unacceptable behavior may be reported by contacting the project team privately at alicevision-team@googlegroups.com. All complaints will be reviewed and investigated and will result in a response that is deemed necessary and appropriate to the circumstances. The project team is obligated to maintain confidentiality with regard to the reporter of an incident. Further details of specific enforcement policies may be posted separately. Project maintainers who do not follow or enforce the Code of Conduct in good faith may face temporary or permanent repercussions as determined by other members of the project's leadership. ## Attribution This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, available at https://www.contributor-covenant.org/version/1/4/code-of-conduct/ [homepage]: https://www.contributor-covenant.org ================================================ FILE: CONTRIBUTING.md ================================================ Contributing to Meshroom =========================== Meshroom relies on a friendly and community-driven effort to create an open source photogrammetry solution. In order to foster a friendly atmosphere where technical collaboration can flourish, we recommend you to read the [code of conduct](CODE_OF_CONDUCT.md). # ![Contributing](/docs/logo/contributing.png) Contributing Workflow --------------------- The contributing workflow relies on [Github Pull Requests](https://help.github.com/articles/using-pull-requests/). 1. If it is an important change, we recommend you to discuss it on the mailing-list before starting implementation. This ensure that the development is aligned with other developpements already started and will be efficiently integrated. 2. Create the corresponding issues. 3. Create a branch and [draft a pull request](https://github.blog/2019-02-14-introducing-draft-pull-requests/) "My new feature" so everyone can follow the development. Explain the implementation in the PR description with links to issues. 4. Implement the new feature(s). Add unit test if needed. One feature per PR is ideal for review, but linked features can be part of the same PR. 5. When it is ready for review, [mark the pull request as ready for review](https://help.github.com/en/articles/changing-the-stage-of-a-pull-request). 6. The reviewers will look over the code and ask for changes, explain problems they found, congratulate the author, etc. using the github comments. 7. After approval, one of the developers with commit approval to the official main repository will merge your fixes into the "develop" branch. ================================================ FILE: COPYING.md ================================================ ## Meshroom License Meshroom is licensed under the [MPL2 license](LICENSE-MPL2.md). ## Third parties licenses * __AliceVision__ [https://github.com/alicevision/AliceVision](https://github.com/alicevision/AliceVision) Copyright (c) 2018 AliceVision contributors. Distributed under the [MPL2 license](https://opensource.org/licenses/MPL-2.0). See [COPYING](https://github.com/alicevision/AliceVision/blob/develop/COPYING.md) for full third parties licenses. * __Python__ [https://www.python.org](https://www.python.org) Copyright (c) 2001-2018 Python Software Foundation. Distributed under the [PSFL V2 license](https://www.python.org/download/releases/2.7/license/). * __Qt/PySide6__ [https://www.qt.io](https://www.qt.io) Copyright (C) 2018 The Qt Company Ltd and other contributors. Distributed under the [LGPL V3 license](https://opensource.org/licenses/LGPL-3.0). * __QtAliceVision__ [https://github.com/alicevision/QtAliceVision](https://github.com/alicevision/QtAliceVision) Copyright (c) 2018 AliceVision contributors. Distributed under the [MPL2 license](https://opensource.org/licenses/MPL-2.0). ================================================ FILE: INSTALL.md ================================================ # Meshroom Installation This guide will help you setup a development environment to launch and contribute to Meshroom. ## Table of Contents 1. [Use prebuilt release](#use-prebuilt-release) 2. [Installation from source code](#installation-from-source-code) 1. [Install minimal dependencies](#install-minimal-dependencies) 1. [Python environment](#python-environment) 2. [Qt/PySide](#qtpyside) 2. [Install dependencies](#install-dependencies) 1. [AliceVision](#alicevision) 2. [QtAliceVision](#qtalicevision) 3. [Install plugins](#install-plugins) 1. [mrSegmentation plugin](#mrsegmentation-plugin) 2. [MeshroomHub](#meshroomhub) 4. [Start Meshroom](#start-meshroom) 3. [Adding custom nodes, templates and plugins](#adding-custom-nodes-templates-and-plugins) 1. [Custom nodes](#custom-nodes) 2. [Custom templates](#custom-templates) 3. [Custom plugins](#custom-plugins) ## Use prebuilt release To quickly run Meshroom without setting up a development environment, follow these simple steps: 1. **Download the prebuilt binaries**: * Visit the [Releases](https://github.com/alicevision/meshroom/releases) page. * Download the latest release that is suitable for your operating system. 2. **Extract the archive**: * On Windows: right-click on the .zip file and select "Extract All", or run `unzip Meshroom-x.y.z.zip` in a terminal. * On Linux: in a terminal, run `tar -xzvf Meshroom.x.y.z.tar.gz`. 3. **Run Meshroom**: in the extracted folder, double-click on the "Meshroom" executable to launch it. ## Installation from source code Get the source code and install runtime requirements: ```bash git clone --recursive https://github.com/alicevision/Meshroom.git cd meshroom ``` ### Install minimal dependencies To use Meshroom nodal system without any visualization option, you can rely on a minimal set of dependencies. #### Python environment * Windows: Python 3 (>=3.9) * Linux: Python 3 (>=3.9) To install all the requirements for runtime, development and packaging, simply run: ```bash pip install -r requirements.txt -r dev_requirements.txt ``` > [!NOTE] > `dev_requirements` is only related to testing and packaging. It is not mandatory to run Meshroom. > [!NOTE] > It is recommended to use a [virtual Python environment](https://docs.python.org/3.9/library/venv.html), like `python -m venv meshroom_venv`. #### Qt/PySide * PySide >= 6.7 > [!WARNING] > For PySide 6.8.0 and over, the following error may occur when leaving Meshroom's homepage: `Cannot load /path/to/pip/install/PySide6/qml/QtQuick/Scene3D/qtquickscene3dplugin.dll: specified module cannot be found`. > This is caused by Qt63DQuickScene3D.dll which seems to be missing from the pip distribution, but can be retrieved from a standard Qt installation. > On recent Linux systems such as Ubuntu 25, this can be resolved by installing `libqt63dquickscene3d6` using the package manager. > Alternatively: > - On Windows, the DLL for MSVC2022_64 can be directly downloaded [here](https://drive.google.com/uc?export=download&id=1vhPDmDQJJfM_hBD7KVqRfh8tiqTCN7Jv). It then needs to be placed in `/path/to/pip/install/PySide6`. > - On Linux, the .so (here, Rocky9-based) can be directly downloaded [here](https://drive.google.com/uc?export=download&id=1dq7rm_Egc-sQF6j6_E55f60INyxt1ega). It then needs to be placed in `/path/to/pip/install/PySide6/Qt/qml/QtQuick/Scene3D`. ### Install dependencies You can install AliceVision to get access to 3D Computer Vision and Machine Learning nodes and pipelines. Additionally, you can install QtAliceVision to get access to Image and 3D data visualization within Meshroom. #### AliceVision [AliceVision](https://github.com/alicevision/AliceVision)'s binaries must be in the path while running Meshroom. The easiest way is to download prebuild binaries from the release. You can download a [Release](https://github.com/alicevision/AliceVision/releases) or extract files from a recent AliceVision build on [Dockerhub](https://hub.docker.com/r/alicevision/alicevision). Alternatively, you can build AliceVision manually from the source code by following this [guide](https://github.com/alicevision/AliceVision/blob/develop/INSTALL.md). Then add the `bin` and `lib` folders into your `PATH` (and `LD_LIBRARY_PATH` on Linux/macOS) environment variables. The following environment variable must always be set with the location of AliceVision's install directory: ``` ALICEVISION_ROOT=/path/to/AliceVision/install/directory ``` AliceVision provides nodes and templates for Meshroom, which need to be declared to Meshroom with the following environment variables: ``` MESHROOM_NODES_PATH={ALICEVISION_ROOT}/share/meshroom MESHROOM_PIPELINE_TEMPLATES_PATH={ALICEVISION_ROOT}/share/meshroom ``` Meshroom also relies on [specific files provided with AliceVision](https://github.com/alicevision/AliceVision/blob/develop/INSTALL.md#environment-variables-to-set-for-meshroom), set through environment variables. If these variables are not set, Meshroom will by default look for them in `{ALICEVISION_ROOT}/share/aliceVision`. > [!NOTE] > You may need to checkout the corresponding Meshroom version/tag to avoid versions incompatibilities. #### QtAliceVision [QtAliceVision](https://github.com/alicevision/QtAliceVision), an additional Qt plugin, can be built to extend Meshroom UI features. Note that it is optional but highly recommended. This plugin uses AliceVision to load and visualize intermediate reconstruction files and OpenImageIO as backend to read images (including RAW/EXR). It also adds support for Alembic file loading in Meshroom's 3D viewport, which allows to visualize sparse reconstruction results (point clouds and cameras). ``` QML2_IMPORT_PATH=/path/to/QtAliceVision/install/qml QT_PLUGIN_PATH=/path/to/QtAliceVision/install ``` ### Install plugins #### mrSegmentation plugin Some templates provided by AliceVision contain nodes that are not packaged with AliceVision. These nodes are part of the mrSegmentation plugin, which can be found [here](https://github.com/MeshroomHub/mrSegmentation). To build and install mrSegmentation, follow this [guide](https://github.com/MeshroomHub/mrSegmentation/blob/main/INSTALL.md). For mrSegmentation nodes to be correctly detected by Meshroom, the following environment variable should be set: ``` MESHROOM_PLUGINS_PATH=/path/to/mrSegmentation ``` #### MeshroomHub You can find many experimental Machine Learning plugins on [MeshroomHub](https://github.com/meshroomHub). ### Start Meshroom - __Launch the User Interface__ ```bash # Windows set PYTHONPATH=%CD% && python meshroom/ui # Linux/macOS PYTHONPATH=$PWD python meshroom/ui ``` On Ubuntu, you may have conflicts between native drivers and mesa drivers. In that case, you need to force usage of native drivers by adding them to the LD_LIBRARY_PATH: `LD_LIBRARY_PATH=/usr/lib/nvidia-340 PYTHONPATH=$PWD python meshroom/ui` You may need to adjust the folder `/usr/lib/nvidia-340` with the correct driver version. - __Launch a 3D reconstruction in command line__ ```bash # Windows: set PYTHONPATH=%CD% && # Linux/macOS: PYTHONPATH=$PWD python bin/meshroom_batch --input INPUT_IMAGES_FOLDER --output OUTPUT_FOLDER ``` ## Adding custom nodes, templates and plugins In addition to the nodes and templates provided by Meshroom and AliceVision, custom ones can be created, loaded by, and used in Meshroom. ### Custom nodes Nodes need to be provided to Meshroom as Python modules, using the `MESHROOM_NODES_PATH` environment variable. For example, to add a set of three custom nodes (`CustomNodeA`, `CustomNodeB` and `CustomNodeC`) to Meshroom, a Python module containing these nodes must be created: ``` ├── folderA │ ├── customNodes │ │ ├── __init__.py │ │ ├── CustomNodeA.py │ │ ├── CustomNodeB.py │ │ └── CustomNodeC.py ├── folderB ``` Its containing folder must then be added to `MESHROOM_NODES_PATH`: - On Windows: ``` set MESHROOM_NODES_PATH=/path/to/folderA;%MESHROOM_NODES_PATH% ``` - On Linux: ``` export MESHROOM_NODES_PATH=/path/to/folderA:$MESHROOM_NODES_PATH ``` > [!NOTE] > A valid Meshroom node is a Python file that contains a class inheriting `meshroom.core.desc.BaseNode`. > Before loading a node, Meshroom checks whether its description (the content of its class) is valid. > If it is not, the node is rejected with an error log describing which part is invalid. ### Custom templates The list of pipelines can also be enriched with custom templates, that are declared to Meshroom with the environment variable `MESHROOM_PIPELINE_TEMPLATES_PATH`. For example, if a couple of custom templates are saved in a folder "customTemplates", the variable should be set as follows: - On Windows: ``` set MESHROOM_PIPELINE_TEMPLATES_PATH=/path/to/customTemplate;%MESHROOM_PIPELINE_TEMPLATES_PATH% ``` - On Linux: ``` export MESHROOM_PIPELINE_TEMPLATES_PATH=/path/to/customTemplates:$MESHROOM_PIPELINE_TEMPLATES_PATH ``` > [!TIP] > A template can be a Meshroom graph of any type, but it is generally expected to be a graph saved in "minimal mode". > In "minimal mode", the .mg file only contains, for each node of the graph, the attributes that have non-default values. > To save a graph in "minimal mode", use the `File > Advanced > Save As Template` menu. ### Custom plugins To add and use custom plugins with Meshroom, follow [**INSTALL_PLUGINS.md**](INSTALL_PLUGINS.md). ================================================ FILE: INSTALL_PLUGINS.md ================================================ # Meshroom plugins installation Plugins are collections of nodes and templates with their own dependencies. Plugin maintainers have flexibility in organizing their code, as Meshroom only requires a few directories to recognize nodes and pipelines. ## Required Structure - **Meshroom folder**: All plugin nodes and templates must be placed within a `./meshroom/` directory - **Configuration file (optional)**: `./meshroom/config.json` file allows to define custom environment variables for the plugin - **Virtual environment (optional)**: If you have specific dependencies, you can create a virtual environment named "venv" in a folder and this Python will be used when computing the node. ## Example Structure For a plugin named "customPlugin", Meshroom expects this layout: ``` ├── customPlugin/ # Plugin root folder │ ├── meshroom/ # Meshroom nodes and pipelines │ │ ├── customNodes1/ # Set of nodes │ │ │ ├── __init__.py # Required to be a python module │ │ │ ├── NodeA.py │ │ │ ├── NodeB.py │ │ ├── customNodes2/ # Another set of nodes if needed │ │ │ ├── __init__.py │ │ │ ├── NodeC.py │ │ │ ├── NodeD.py │ │ ├── customTemplate1.mg # Ready-to-use pipeline templates │ │ ├── customTemplate2.mg │ │ ├── config.json # Optional plugin configuration file │ ├── venv/ # Optional virtual environment with installed dependencies │ └── ... # Custom code (any structure) ``` ## Loading the Plugin The "customPlugin" will be loaded automatically when Meshroom starts by setting the `MESHROOM_PLUGINS_PATH` environment variable: - On Windows: ``` set MESHROOM_PLUGINS_PATH=/path/to/customPlugin;%MESHROOM_PLUGINS_PATH% ``` - On Linux: ``` export MESHROOM_PLUGINS_PATH=/path/to/customPlugin:$MESHROOM_PLUGINS_PATH ``` ================================================ FILE: LICENSE-MPL2.md ================================================ Mozilla Public License Version 2.0 ================================== 1. Definitions -------------- 1.1. "Contributor" means each individual or legal entity that creates, contributes to the creation of, or owns Covered Software. 1.2. "Contributor Version" means the combination of the Contributions of others (if any) used by a Contributor and that particular Contributor's Contribution. 1.3. "Contribution" means Covered Software of a particular Contributor. 1.4. "Covered Software" means Source Code Form to which the initial Contributor has attached the notice in Exhibit A, the Executable Form of such Source Code Form, and Modifications of such Source Code Form, in each case including portions thereof. 1.5. "Incompatible With Secondary Licenses" means (a) that the initial Contributor has attached the notice described in Exhibit B to the Covered Software; or (b) that the Covered Software was made available under the terms of version 1.1 or earlier of the License, but not also under the terms of a Secondary License. 1.6. "Executable Form" means any form of the work other than Source Code Form. 1.7. "Larger Work" means a work that combines Covered Software with other material, in a separate file or files, that is not Covered Software. 1.8. "License" means this document. 1.9. "Licensable" means having the right to grant, to the maximum extent possible, whether at the time of the initial grant or subsequently, any and all of the rights conveyed by this License. 1.10. "Modifications" means any of the following: (a) any file in Source Code Form that results from an addition to, deletion from, or modification of the contents of Covered Software; or (b) any new file in Source Code Form that contains any Covered Software. 1.11. "Patent Claims" of a Contributor means any patent claim(s), including without limitation, method, process, and apparatus claims, in any patent Licensable by such Contributor that would be infringed, but for the grant of the License, by the making, using, selling, offering for sale, having made, import, or transfer of either its Contributions or its Contributor Version. 1.12. "Secondary License" means either the GNU General Public License, Version 2.0, the GNU Lesser General Public License, Version 2.1, the GNU Affero General Public License, Version 3.0, or any later versions of those licenses. 1.13. "Source Code Form" means the form of the work preferred for making modifications. 1.14. "You" (or "Your") means an individual or a legal entity exercising rights under this License. For legal entities, "You" includes any entity that controls, is controlled by, or is under common control with You. For purposes of this definition, "control" means (a) the power, direct or indirect, to cause the direction or management of such entity, whether by contract or otherwise, or (b) ownership of more than fifty percent (50%) of the outstanding shares or beneficial ownership of such entity. 2. License Grants and Conditions -------------------------------- 2.1. Grants Each Contributor hereby grants You a world-wide, royalty-free, non-exclusive license: (a) under intellectual property rights (other than patent or trademark) Licensable by such Contributor to use, reproduce, make available, modify, display, perform, distribute, and otherwise exploit its Contributions, either on an unmodified basis, with Modifications, or as part of a Larger Work; and (b) under Patent Claims of such Contributor to make, use, sell, offer for sale, have made, import, and otherwise transfer either its Contributions or its Contributor Version. 2.2. Effective Date The licenses granted in Section 2.1 with respect to any Contribution become effective for each Contribution on the date the Contributor first distributes such Contribution. 2.3. Limitations on Grant Scope The licenses granted in this Section 2 are the only rights granted under this License. No additional rights or licenses will be implied from the distribution or licensing of Covered Software under this License. Notwithstanding Section 2.1(b) above, no patent license is granted by a Contributor: (a) for any code that a Contributor has removed from Covered Software; or (b) for infringements caused by: (i) Your and any other third party's modifications of Covered Software, or (ii) the combination of its Contributions with other software (except as part of its Contributor Version); or (c) under Patent Claims infringed by Covered Software in the absence of its Contributions. This License does not grant any rights in the trademarks, service marks, or logos of any Contributor (except as may be necessary to comply with the notice requirements in Section 3.4). 2.4. Subsequent Licenses No Contributor makes additional grants as a result of Your choice to distribute the Covered Software under a subsequent version of this License (see Section 10.2) or under the terms of a Secondary License (if permitted under the terms of Section 3.3). 2.5. Representation Each Contributor represents that the Contributor believes its Contributions are its original creation(s) or it has sufficient rights to grant the rights to its Contributions conveyed by this License. 2.6. Fair Use This License is not intended to limit any rights You have under applicable copyright doctrines of fair use, fair dealing, or other equivalents. 2.7. Conditions Sections 3.1, 3.2, 3.3, and 3.4 are conditions of the licenses granted in Section 2.1. 3. Responsibilities ------------------- 3.1. Distribution of Source Form All distribution of Covered Software in Source Code Form, including any Modifications that You create or to which You contribute, must be under the terms of this License. You must inform recipients that the Source Code Form of the Covered Software is governed by the terms of this License, and how they can obtain a copy of this License. You may not attempt to alter or restrict the recipients' rights in the Source Code Form. 3.2. Distribution of Executable Form If You distribute Covered Software in Executable Form then: (a) such Covered Software must also be made available in Source Code Form, as described in Section 3.1, and You must inform recipients of the Executable Form how they can obtain a copy of such Source Code Form by reasonable means in a timely manner, at a charge no more than the cost of distribution to the recipient; and (b) You may distribute such Executable Form under the terms of this License, or sublicense it under different terms, provided that the license for the Executable Form does not attempt to limit or alter the recipients' rights in the Source Code Form under this License. 3.3. Distribution of a Larger Work You may create and distribute a Larger Work under terms of Your choice, provided that You also comply with the requirements of this License for the Covered Software. If the Larger Work is a combination of Covered Software with a work governed by one or more Secondary Licenses, and the Covered Software is not Incompatible With Secondary Licenses, this License permits You to additionally distribute such Covered Software under the terms of such Secondary License(s), so that the recipient of the Larger Work may, at their option, further distribute the Covered Software under the terms of either this License or such Secondary License(s). 3.4. Notices You may not remove or alter the substance of any license notices (including copyright notices, patent notices, disclaimers of warranty, or limitations of liability) contained within the Source Code Form of the Covered Software, except that You may alter any license notices to the extent required to remedy known factual inaccuracies. 3.5. Application of Additional Terms You may choose to offer, and to charge a fee for, warranty, support, indemnity or liability obligations to one or more recipients of Covered Software. However, You may do so only on Your own behalf, and not on behalf of any Contributor. You must make it absolutely clear that any such warranty, support, indemnity, or liability obligation is offered by You alone, and You hereby agree to indemnify every Contributor for any liability incurred by such Contributor as a result of warranty, support, indemnity or liability terms You offer. You may include additional disclaimers of warranty and limitations of liability specific to any jurisdiction. 4. Inability to Comply Due to Statute or Regulation --------------------------------------------------- If it is impossible for You to comply with any of the terms of this License with respect to some or all of the Covered Software due to statute, judicial order, or regulation then You must: (a) comply with the terms of this License to the maximum extent possible; and (b) describe the limitations and the code they affect. Such description must be placed in a text file included with all distributions of the Covered Software under this License. Except to the extent prohibited by statute or regulation, such description must be sufficiently detailed for a recipient of ordinary skill to be able to understand it. 5. Termination -------------- 5.1. The rights granted under this License will terminate automatically if You fail to comply with any of its terms. However, if You become compliant, then the rights granted under this License from a particular Contributor are reinstated (a) provisionally, unless and until such Contributor explicitly and finally terminates Your grants, and (b) on an ongoing basis, if such Contributor fails to notify You of the non-compliance by some reasonable means prior to 60 days after You have come back into compliance. Moreover, Your grants from a particular Contributor are reinstated on an ongoing basis if such Contributor notifies You of the non-compliance by some reasonable means, this is the first time You have received notice of non-compliance with this License from such Contributor, and You become compliant prior to 30 days after Your receipt of the notice. 5.2. If You initiate litigation against any entity by asserting a patent infringement claim (excluding declaratory judgment actions, counter-claims, and cross-claims) alleging that a Contributor Version directly or indirectly infringes any patent, then the rights granted to You by any and all Contributors for the Covered Software under Section 2.1 of this License shall terminate. 5.3. In the event of termination under Sections 5.1 or 5.2 above, all end user license agreements (excluding distributors and resellers) which have been validly granted by You or Your distributors under this License prior to termination shall survive termination. ************************************************************************ * * * 6. Disclaimer of Warranty * * ------------------------- * * * * Covered Software is provided under this License on an "as is" * * basis, without warranty of any kind, either expressed, implied, or * * statutory, including, without limitation, warranties that the * * Covered Software is free of defects, merchantable, fit for a * * particular purpose or non-infringing. The entire risk as to the * * quality and performance of the Covered Software is with You. * * Should any Covered Software prove defective in any respect, You * * (not any Contributor) assume the cost of any necessary servicing, * * repair, or correction. This disclaimer of warranty constitutes an * * essential part of this License. No use of any Covered Software is * * authorized under this License except under this disclaimer. * * * ************************************************************************ ************************************************************************ * * * 7. Limitation of Liability * * -------------------------- * * * * Under no circumstances and under no legal theory, whether tort * * (including negligence), contract, or otherwise, shall any * * Contributor, or anyone who distributes Covered Software as * * permitted above, be liable to You for any direct, indirect, * * special, incidental, or consequential damages of any character * * including, without limitation, damages for lost profits, loss of * * goodwill, work stoppage, computer failure or malfunction, or any * * and all other commercial damages or losses, even if such party * * shall have been informed of the possibility of such damages. This * * limitation of liability shall not apply to liability for death or * * personal injury resulting from such party's negligence to the * * extent applicable law prohibits such limitation. Some * * jurisdictions do not allow the exclusion or limitation of * * incidental or consequential damages, so this exclusion and * * limitation may not apply to You. * * * ************************************************************************ 8. Litigation ------------- Any litigation relating to this License may be brought only in the courts of a jurisdiction where the defendant maintains its principal place of business and such litigation shall be governed by laws of that jurisdiction, without reference to its conflict-of-law provisions. Nothing in this Section shall prevent a party's ability to bring cross-claims or counter-claims. 9. Miscellaneous ---------------- This License represents the complete agreement concerning the subject matter hereof. If any provision of this License is held to be unenforceable, such provision shall be reformed only to the extent necessary to make it enforceable. Any law or regulation which provides that the language of a contract shall be construed against the drafter shall not be used to construe this License against a Contributor. 10. Versions of the License --------------------------- 10.1. New Versions Mozilla Foundation is the license steward. Except as provided in Section 10.3, no one other than the license steward has the right to modify or publish new versions of this License. Each version will be given a distinguishing version number. 10.2. Effect of New Versions You may distribute the Covered Software under the terms of the version of the License under which You originally received the Covered Software, or under the terms of any subsequent version published by the license steward. 10.3. Modified Versions If you create software not governed by this License, and you want to create a new license for such software, you may create and use a modified version of this License if you rename the license and remove any references to the name of the license steward (except to note that such modified license differs from this License). 10.4. Distributing Source Code Form that is Incompatible With Secondary Licenses If You choose to distribute Source Code Form that is Incompatible With Secondary Licenses under the terms of this version of the License, the notice described in Exhibit B of this License must be attached. Exhibit A - Source Code Form License Notice ------------------------------------------- This Source Code Form is subject to the terms of the Mozilla Public License, v. 2.0. If a copy of the MPL was not distributed with this file, You can obtain one at http://mozilla.org/MPL/2.0/. If it is not possible or desirable to put the notice in a particular file, then You may include the notice in a location (such as a LICENSE file in a relevant directory) where a recipient would be likely to look for such a notice. You may add additional accurate notices of copyright ownership. Exhibit B - "Incompatible With Secondary Licenses" Notice --------------------------------------------------------- This Source Code Form is "Incompatible With Secondary Licenses", as defined by the Mozilla Public License, v. 2.0. ================================================ FILE: NODE_DEVELOPMENT.md ================================================ # Meshroom Node Development ## Node Creation This guide shows how to implement three common Meshroom node types: Python-based `Node`, external-executable `CommandLineNode`, and non-computational `InputNode`. ### 1. Node (Pure Python) Use `desc.Node` when your logic runs in Python. Implement `process(self, node)` to produce outputs. #### Example: Generate a file ```python from meshroom.core import desc class GenerateFile(desc.Node): category = "Custom" inputs = [ desc.File(name="input", label="Input", description="", value=""), desc.IntParam(name="count", label="Count", description="", value=1), ] outputs = [ desc.File(name="output", label="Output", description="", value="{nodeCacheFolder}/out.txt"), ] def process(self, node): # Implement your computation logic here with open(node.output.value, "w") as f: f.write(f"Processed {node.input.value} ({node.count.value})\n") ``` In this example, the path of the output file is an expression that will always be up-to-date in Meshroom and the corresponding file will be created by the node's computation. #### Example: Compute values ```python class AddInt(desc.Node): category = "Custom" inputs = [ desc.IntParam(name="a", label="Count", description="", value=1), desc.IntParam(name="b", label="Count", description="", value=2), ] outputs = [ # Dynamic output value desc.IntParam(name="outputInt", label="Count", description="", value=None), ] def process(self, node): # Implement your logic here; set output attributes. node.outputInt.value = node.a.value + node.b.value ``` In this example, the output param value will ve valid in Meshroom only at the end of the node computation. ### 2. CommandLineNode (external executable) Use `desc.CommandLineNode` to wrap an external binary. Define a `commandLine` template with `{variable}` placeholders. Meshroom expands it via `buildCommandLine(chunk)` and executes the result. #### Example ```python from meshroom.core import desc class MyCmdNode(desc.CommandLineNode): commandLine = "mytool --input {inputValue} --output {outputValue}" inputs = [ desc.File(name="input", label="Input", description="", value=""), ] outputs = [ desc.File(name="output", label="Output", description="", value="{nodeCacheFolder}/out.txt"), ] ``` ### 3. InputNode (non-computational placeholder) Use `desc.InputNode` for nodes that only hold data and do not run computation. #### Example: Input Node ```python from meshroom.core import desc class MyInputNode(desc.InputNode): category = "Custom" inputs = [ desc.File(name="file", label="File", description="", value=""), ] ``` #### Example: Input Node with Initialization The InitNodes could be combined with `desc.InitNode` to implement `initialize` for command line batching or initialization from drag&drop. ```python from meshroom.core import desc class MyInputNode(desc.InputNode, desc.InitNode): category = "Custom" inputs = [ desc.File(name="file", label="File", description="", value=""), ] def initialize(self, node, inputs, recursiveInputs): # Populate attributes from command-line inputs. if inputs: node.file.value = inputs[0] ``` ## Attribute Types Available in Meshroom Nodes Meshroom provides several attribute types you can use in a node’s `inputs` and `outputs`. They are defined in `meshroom.core.desc` and organized into basic parameters, compound containers, geometry helpers, and shape annotations. ### Basic Parameters | Type | Description | Common Options | |------|-------------|----------------| | `BoolParam` | Boolean toggle. | `value` (bool) | | `IntParam` | Integer with optional range. | `range=(min, max, step)` | | `FloatParam` | Floating-point with optional range. | `range=(min, max, step)` | | `StringParam` | Free-form string. | `value` (str) | | `File` | File or directory path. | `value` (str) | | `ChoiceParam` | Single or multiple selection from a list. | `values=[...]`, `exclusive` | | `ColorParam` | RGBA color. | `value` (list/tuple) | | `PushButtonParam` | Action button in UI; no stored value. | N/A | ### Compound Containers | Type | Description | Key Args | |------|-------------|----------| | `ListAttribute` | Homogeneous list of elements defined by `elementDesc`. | `elementDesc`, `joinChar` | | `GroupAttribute` | Fixed collection of heterogeneous child attributes (`items`). | `items`, `joinChar` | Both inherit from `Attribute` and support nesting (lists of groups, groups with lists). #### Example: Parameter Types ```python from meshroom.core import desc class ParameterTypesSample(desc.Node): category = "Custom" inputs = [ desc.BoolParam(name="boolParam", label="Boolean", description="", value=False), desc.IntParam(name="intParam", label="Integer", description="", value=10, range=(0, 100, 1)), desc.FloatParam(name="floatParam", label="Float", description="", value=3.14, range=(0.0, 10.0, 0.1)), desc.StringParam(name="stringParam", label="String", description="", value="default"), desc.File(name="fileParam", label="File", description="", value=""), desc.ChoiceParam(name="choiceParam", label="Choice", description="", value="opt1", values=["opt1", "opt2", "opt3"], exclusive=True), desc.ColorParam(name="colorParam", label="Color", description="", value=[1.0, 0.0, 0.0, 1.0]), desc.PushButtonParam(name="buttonParam", label="Button", description=""), desc.ListAttribute( name="fileList", label="File List", description="", elementDesc=desc.File(name="file", label="File", description="", value=""), joinChar=" " ), desc.GroupAttribute( name="inputGroup", label="Input Group", description="Group with bool, int, string and file", items=[ desc.BoolParam(name="groupBool", label="Boolean", description="", value=True), desc.IntParam(name="groupInt", label="Integer", description="", value=42, range=(0, 100, 1)), desc.StringParam(name="groupString", label="String", description="", value="groupValue"), desc.File(name="groupFile", label="File", description="", value="") ] ) ] outputs = [ desc.File(name="outputFile", label="Output File", description="", value="{nodeCacheFolder}/output.txt") ] def process(self, node): with open(node.outputFile.value, "w") as f: f.write(f"{node.boolParam.value},{node.intParam.value},{node.floatParam.value},{node.stringParam.value},{node.fileParam.value},{node.choiceParam.value},{node.colorParam.value},{len(node.fileList.value)},{node.inputGroup.groupBool.value},{node.inputGroup.groupInt.value},{node.inputGroup.groupString.value},{node.inputGroup.groupFile.value}\n") ``` ### Geometry Helpers Convenient groups for 2D geometry, built from `GroupAttribute` and `FloatParam`: | Type | Fields | Example | |------|--------|---------| | `Size2d` | `width`, `height` (float) | `Size2d(name="sz", ..., width=1920, height=1080)` | | `Vec2d` | `x`, `y` (float) | `Vec2d(name="vec", ..., x=0.0, y=1.0)` | ### Attribute Properties - **Name**: Used to access attributes from script. - **Label**: Label used for the display in the Node Editor. - **Description**: Tooltip used in the Node Editor. - **Range constraints**: `IntParam` and `FloatParam` accept `range=(min, max, step)` to bound values. - **Enabled**: Parameters can be enabled or disabled dynamically (using a lamda). - **Advanced**: Parameters can be declared as advanced parameters, so they are hidden by default but could be activated in the UI for experts or developpers. - **Exposed** in the GraphEditor: Files are exposed in the nodal view by default, other type are hidden by default, but it can be customized per attribute. - **Dynamic outputs**: Set `value=None` in an output attribute to mark it as dynamically computed. - **Keyable attributes**: Enable per-key values (e.g., per-view) with `keyable=True` and `keyType`. Supported on basic params and shapes. - **JoinChar**: Controls string serialization for `ListAttribute` and `GroupAttribute` when used in command lines. ### Advanced: Shape Parameters Used for UI overlays/annotations; they support `keyable` per-view values: | Type | Description | Example | |------|-------------|---------| | `Point2d` | 2D point (`x`, `y`). | `Point2d(name="pt", ...)` | | `Line2d` | 2D line defined by two points. | `Line2d(name="ln", ...)` | | `Rectangle` | Axis-aligned rectangle. | `Rectangle(name="rect", ...)` | | `Circle` | Circle with center and radius. | `Circle(name="c", ...)` | | `ShapeList` | List of a single shape type (`shape`). | `ShapeList(name="pts", shape=Point2d(...))` | ## Node Descriptor Properties | Property | Type | Description | Default | |----------|------|-------------|---------| | Class documentation | str | Detailed description of the node's purpose | "" | | `category` | str | Organizational category in the node library | "Other" | | `cpu` | Level or callable | CPU resource requirement level | Level.NORMAL | | `ram` | Level or callable | Memory resource requirement level | Level.NORMAL | | `gpu` | Level or callable | GPU resource requirement level | Level.NONE | | `size` | Size object | Parallelization size configuration | StaticNodeSize(1) | | `parallelization` | Parallelization | Chunk division settings | None | ### Example: Basic Node with Properties ```python class SampleNode(desc.Node): """This is the Node documentation that will be available in the Node Editor.""" category = "Custom Node Category" # Used in the UI to group nodes in the menu size = desc.DynamicNodeSize("inputFiles") # Size used to define the number of chunks for parallelization # Resource levels (`cpu`, `gpu`, `ram`) are used for farm scheduling on suitable hardware cpu = Level.NORMAL # Need standard amount of CPU ram = Level.HIGH # Requires large amount of RAM gpu = Level.NONE # Do not need GPU ``` Resource levels can also be set as callables receiving a node instance, allowing them to be determined dynamically based on the node's input parameters: ```python class SampleNode(desc.Node): # Dynamically require a GPU based on an input parameter gpu = lambda node: desc.Level.INTENSIVE if node.attribute("useGpu").value else desc.Level.NONE ``` The resolved value for a node instance is accessible via the `cpu`, `gpu`, and `ram` properties on the node object (e.g. `node.cpu`, `node.gpu`, `node.ram`). ## Parallelizing a Node Meshroom enables node parallelization by splitting work into independent chunks that can be distributed on multiple workstations on compute farm. Configure parallelization by setting `size` and `parallelization` properties on your node descriptor. ### Configuration #### Size Strategies - **StaticNodeSize**: Fixed number of tasks - **DynamicNodeSize**: Size based on an input attribute (list length or linked node size) - **MultiDynamicNodeSize**: Sum of sizes from multiple input attributes - **callable**: A callable (e.g. a lambda) receiving the node instance: `lambda node: node.sizeInput.value` #### Parallelization Settings Set `parallelization` to control chunk division: - `blockSize`: Items per chunk - `staticNbBlocks`: Fixed number of chunks (alternative to blockSize) ### Implementation Examples #### CommandLineNode with Static Parallelization ```python class MyParallelCmd(desc.CommandLineNode): commandLine = "mytool --input {inputValue} --output {outputValue}" commandLineRange = "--range {rangeStart} {rangeEnd}" # Specific way to precise the range to compute on the command line size = desc.StaticNodeSize(100) # 100 items total parallelization = desc.Parallelization(blockSize=10) # 10 chunks of 10 items ``` #### Node with Dynamic Size ```python class MyParallelNode(desc.Node): size = desc.DynamicNodeSize("inputList") # Size matches list length parallelization = desc.Parallelization(blockSize=3) # Create a chunk every 3 elements in the list def processChunk(self, chunk): # Process chunk.range.iteration pass ``` ### Range and Chunk Behavior Each chunk receives a `Range` object with: - `iteration`: Chunk index - `start`/`end`: Item indices for this chunk - `blockSize`: Items per chunk - `nbBlocks`: Total chunks For `CommandLineNode`, range placeholders are automatically injected into `commandLineRange` when `node.isParallelized` and `node.size > 1`. ## Installation See [INSTALL_PLUGINS.md](./INSTALL_PLUGINS.md) ================================================ FILE: README.md ================================================ # ![Meshroom - 3D Reconstruction Software](/docs/logo/banner-meshroom.png) Meshroom is an open-source, node-based visual programming framework—a flexible toolbox for creating, managing, and executing complex data processing pipelines. Meshroom uses a nodal system where each node represents a specific operation, and output attributes can seamlessly feed into subsequent steps. When a node’s attribute is modified, only the affected downstream nodes are invalidated, while cached intermediate results are reused to minimize unnecessary computation. Meshroom supports both local and distributed execution, enabling efficient parallel processing on render farms. It also includes interactive widgets for visualizing images and 3D data. Official releases come with built-in plugins for computer vision and machine learning tasks. [![Ask DeepWiki](https://deepwiki.com/badge.svg)](https://deepwiki.com/alicevision/Meshroom) [![CII Best Practices](https://bestpractices.coreinfrastructure.org/projects/2997/badge)](https://bestpractices.coreinfrastructure.org/projects/2997) [![Build status](https://github.com/alicevision/Meshroom/actions/workflows/continuous-integration.yml/badge.svg?branch=develop)](https://github.com/alicevision/Meshroom/actions/workflows/continuous-integration.yml) # Get the project You can [download pre-compiled binaries for the latest release](https://github.com/alicevision/meshroom/releases). If you want to build it yourself, see [**INSTALL.md**](INSTALL.md) to setup the project and pre-requisites. To use Meshroom with custom plugins, see [**INSTALL_PLUGINS.md**](INSTALL_PLUGINS.md). # Concepts - **Graph**: A collection of interconnected nodes that defines the sequence of operations to represent your complete data processing workflow. - **Nodes**: The fundamental building blocks, each performing a specific task. Nodes are connected through edges that represent the flow of data between them. - **Attributes**: Parameters that control how each node behaves. When an attribute is modified, it triggers the invalidation of all connected downstream nodes while preserving cached intermediate results. - **Templates**: Ready-to-use pipeline configurations provided by plugins. You can customize existing templates or create and save your own. - **Local / Renderfarm**: Choose between local processing or distributed computation on render farms. You can monitor progress, review logs, track resource consumption, and use both modes simultaneously as Meshroom manages node locking during external computation. - **Custom Plugins**: Extend Meshroom's capabilities by creating your own nodes in Python or by integrating external command-line tools. # User Interface The Meshroom UI is divided into several key areas: - **Graph Editor**: The central area where nodes are placed and connected to form a processing pipeline. - **Node Editor**: It contains multiple tabs with: - **Attributes**: Displays the attributes and parameters of the selected node. - **Log**: Displays execution logs and error messages. - **Statistics**: Displays resource consumption - **Status**: Display some technical information on the node (workstation, start/end time, etc.) - **Documentation**: Node Documentation. - **Notes**: Change label or put some notes on the node to know why it’s used in this graph. - **2D & 3D Viewer**: Visualizes the output of certain nodes. - **Image Gallery**: Visualize the list of input files. # Manual and Tutorials - [Meshroom Manual](https://meshroom-manual.readthedocs.io) - [Meshroom FAQ](https://github.com/alicevision/meshroom/wiki) # Plugins bundled by default ## AliceVision Plugin [AliceVision Website](http://alicevision.org) [AliceVision Repository](https://github.com/alicevision/AliceVision) AliceVision provides state-of-the-art 3D Computer Vision and Machine Learning algorithms that analyze and understand image content to transform collections of regular 2D photographs into detailed 3D models, camera positions, and scene geometry. Born from collaboration between academia and industry, it delivers research-grade algorithms with production-level robustness and quality. The AliceVision plugin offers comprehensive pipelines for: - **3D Reconstruction** from multi-view images ([pipeline overview](http://alicevision.github.io/#photogrammetry), [results on Sketchfab](http://sketchfab.com/AliceVision)) - **Camera Tracking** for camera motion estimation - **HDR Fusion** from multi-bracketed photography - **Panorama Stitching** including fisheye support and motorized head systems - **Photometric Stereo** for geometric reconstruction from a single view with multiple lightings - **Multi-View Photometric Stereo** combining photogrammetry with photometric stereo ## Segmentation Plugin [MrSegmentation](https://github.com/meshroomHub/mrSegmentation): A set of nodes for AI-powered image segmentation from natural language prompts. The plugin leverages foundation models to automatically identify and isolate specific objects or regions in images based on textual descriptions, enabling intuitive content-aware processing workflows. # Other plugins See [MeshroomHub](https://github.com/meshroomHub) for more plugins. ## DepthEstimation Plugin [MrDepthEstimation](https://github.com/meshroomHub/mrDepthEstimation): A set of nodes for AI-based monocular depth estimation from image sequences. The plugin leverages deep learning models to predict depth information from single images, enabling depth estimation in new scenarios. ## RoMa Plugin [MrRoma](https://github.com/meshroomHub/mrRoma): A set of nodes for RoMa (robust dense feature matching). The plugin leverages foundation models to provide pixel-dense correspondence estimation with reliable certainty maps, enabling robust matching even under extreme variations in scale, illumination, viewpoint, and texture. ## GSplat Plugin [MrGSplat](https://github.com/meshroomHub/mrGSplat): A set of nodes for 3D Gaussian Splatting reconstruction. The plugin integrates seamlessly with AliceVision's photogrammetry pipeline, allowing users to create Gaussian splat representations from multi-view images and to render new viewpoints. ## Research Plugin [Meshroom Research](https://github.com/meshroomHub/MeshroomResearch) A research-oriented plugin for evaluating and benchmarking cutting-edge Machine Learning algorithms in 3D Computer Vision. The plugin provides experimental nodes and evaluation frameworks to test new methodologies, compare algorithm performance, and validate research innovations before integration into production pipelines. ## MicMac Plugin [MeshroomMicMac](https://github.com/alicevision/MeshroomMicMac) An exploratory plugin integrating MicMac's photogrammetric algorithms into Meshroom workflows. MicMac is a mature open-source photogrammetric software developed by the National Institute of Geographic and Forestry Information (French Mapping Agency, IGN) and the National School of Geographic Sciences (ENSG) within the LASTIG lab, offering specialized tools for surveying and mapping applications. While the plugin does not yet support Meshroom's full invalidation system, it provides fully functional pipelines for users seeking MicMac's specific photogrammetric capabilities. ## Geolocation Plugin [MrGeolocation](https://github.com/meshroomHub/mrGeolocation) A plugin for geospatial integration that extracts GPS data from photographs and downloads contextual geographic information. The plugin automatically places 3D reconstructions within their real-world geographical environment by retrieving worldwide 2D maps (OpenStreetMap), global elevation models (NASA datasets), and high-resolution 3D Lidar models where available (France via IGN open data). This enables accurate georeferencing and contextual visualization of photogrammetric reconstructions. # License The project is released under MPLv2, see [**COPYING.md**](COPYING.md). # Citation ``` @inproceedings{alicevision2021, title={{A}liceVision {M}eshroom: An open-source {3D} reconstruction pipeline}, author={Carsten Griwodz and Simone Gasparini and Lilian Calvet and Pierre Gurdjos and Fabien Castan and Benoit Maujean and Gregoire De Lillo and Yann Lanthony}, booktitle={Proceedings of the 12th ACM Multimedia Systems Conference - {MMSys '21}}, doi = {10.1145/3458305.3478443}, publisher = {ACM Press}, year = {2021} } ``` # Contributing We welcome contributions! Check out our [Contribution Guidelines](CONTRIBUTING.md) to get started. Whether you are a developer, designer, or documentation enthusiast, there is a place for you in the Meshroom community. # Contact Use the public mailing-list to ask questions or request features. It is also a good place for informal discussions like sharing results, interesting related technologies or publications: [forum@alicevision.org](https://groups.google.com/g/alicevision) You can also contact the core team privately on: [team@alicevision.org](mailto:team@alicevision.org). ================================================ FILE: RELEASING.md ================================================ ### Versioning Version = MAJOR (>=1 year), MINOR (>= 1 month), PATCH Version Status = Develop / Release ### Git Branches develop: active development branch master: latest release vMAJOR.MINOR: release branch Tags vMAJOR.MINOR.PATCH: tag for each release ### Release Process - Prepare the AliceVision release: https://github.com/alicevision/AliceVision - Update INSTALL.md and requirements.txt if needed - Source code - Create branch from develop: "rcMAJOR.MINOR" - Modify version in code, version status to RELEASE (meshroom/__init__.py) - Update the version of all the templates so their version corresponds to the release - Create Release note (using https://github.com/cbentejac/github-generate-release-note) - ``` ./github-generate-release-note.py -o alicevision -r Meshroom -m "Meshroom MAJOR.MINOR.PATCH" --highlights majorFeature feature --label-include bugfix ci,scope:doc,scope:build -s updated-asc ``` - PR to develop: "Release MAJOR.MINOR" - Build - Build docker & push to dockerhub - Build windows - Git - Merge "rcMAJOR.MINOR" into "develop" - Push "develop" into "master" - Create branch: vMAJOR.MINOR - Create tag: vMAJOR.MINOR.PATCH on Meshroom, qtAliceVision - Create branch from develop: "startMAJOR.MINOR" - Upload binaries on fosshub - Fill up Github release note - Prepare "develop" for new developments - Upgrade MINOR and reset version status to Develop - PR to develop: "Start Development MAJOR.MINOR" - Communication - Email on mailing-list: alicevision@googlegroups.com - Message on linkedin: https://www.linkedin.com/groups/13573776 - Message on twitter: https://twitter.com/alicevision_org ### Upgrade a Release with a PATCH version - Source code - Create branch from rcMAJOR.MINOR: "rcMAJOR.MINOR.PATCH" - Cherry-pick specific commits or rebase required PR - Modify version in code - Update release note - Build step - Uploads - Github release note - Email on mailing-list ================================================ FILE: WINDOWS_EXE.md ================================================ # Meshroom executable generation on Windows This describes how to generate Meshroom's executable on Windows. This does not include any plugin, only Meshroom itself. ## Set helper environment variables ```bash set SRC_ROOT=/path/to/Meshroom/repository set PYTHON=/path/to/Python/Python311/python.exe set RELEASE_VERSION=2026.x.x set MESHROOM_EXE_DIR=/path/to/Meshroom-%RELEASE_VERSION% ``` ## Meshroom build ### Prepare environment ```bash cd %SRC_ROOT% %PYTHON% -m venv venv call venv\Scripts\activate.bat pip install -r requirements.txt -r dev_requirements.txt ``` ### Executable generation ```bash python setup.py install_exe -d %MESHROOM_EXE_DIR% deactivate ``` > [!IMPORTANT] > PySide6 >= 6.8.0 misses a DLL in its pip installation. If it is not manually added, Meshroom will run into the following error when attempting to leave the homepage and displaying the application: > `Cannot load /path/to/pip/install/PySide6/qml/QtQuick/Scene3D/qtquickscene3dplugin.dll: specified module cannot be found`. > The missing DLL is Qt63DQuickScene3D.dll and can be downloaded [here](https://drive.google.com/uc?export=download&id=1vhPDmDQJJfM_hBD7KVqRfh8tiqTCN7Jv) (for MSVC2022_64). Alternatively, it can be retrieved from any Qt local installation. > It needs to be placed in `%MESHROOM_EXE_DIR%/lib/PySide6`. ### Clean the packages Get rid of all the things that are unnecessary for Meshroom. This will lighten the final package. ```bash cd %MESHROOM_EXE_DIR%/lib/PySide6 del /s /q Qt6Web*.dll Qt6Designer*.dll *.exe rmdir /s /q resources translations typesystems examples include ``` ================================================ FILE: bin/meshroom_batch ================================================ #!/usr/bin/env python import argparse import json import logging import os import re import sys import meshroom.core.graph from meshroom.common import strtobool from meshroom import setupEnvironment, logStringToPython def parseInitInputs(inputs: list[str]) -> dict[str, str]: """Utility method for parsing the input and inputRecursive arguments. Args: inputs: Command line values in format 'nodeName=value' or just 'value' to set it on all init nodes Returns: Dict mapping node names (or empty string if it applies to all) to their input values Raises: ValueError: If input format is invalid """ mapInputs = {} for inp in inputs: # Stop after the first occurrence inputGroup = inp.split('=', 1) nodeName = inputGroup[0] if len(inputGroup) == 2 else "" nodeInputs = inputGroup[-1].split(',') mapInputs[nodeName] = [os.path.abspath(path) for path in nodeInputs] return mapInputs setupEnvironment() meshroom.core.initPipelines() parser = argparse.ArgumentParser( prog='meshroom_batch', description='Launch a Meshroom pipeline from command line.', add_help=True, formatter_class=argparse.RawTextHelpFormatter, epilog=''' Examples: 1. Process a pipeline in command line: meshroom_batch -p cameraTracking -i /input/path -o /output/path -s /path/to/store/the/project.mg 2. Submit a pipeline on renderfarm: meshroom_batch -p cameraTracking -i /input/path -o /output/path -s /path/to/store/the/project.mg --submit See "meshroom_compute -h" to compute an existing project from command line. Additional Resources: Website: https://alicevision.org Manual: https://meshroom-manual.readthedocs.io Forum: https://groups.google.com/g/alicevision Tutorials: https://www.youtube.com/c/AliceVisionOrg Contribute: https://github.com/alicevision/Meshroom ''') general_group = parser.add_argument_group('General Options') general_group.add_argument( '-i', '--input', metavar='FILE FOLDER NODEINSTANCE=FILE,FOLDER,...', type=str, nargs='*', default=[], help='Input files and folders to process. ' 'When multiple Init Nodes exist in the pipeline, inputs are applied to all by default. ' 'To target a specific Init Node, use the format Node1=input1,input2 Node2=input3') general_group.add_argument( '-I', '--inputRecursive', metavar='FOLDER FOLDER_2 NODEINSTANCE=FOLDER,FOLDER_2,...', type=str, nargs='*', default=[], help='Recursively scan these directories for input files.') general_group.add_argument( '-p', '--pipeline', metavar='FILE.mg / PIPELINE', type=str, default=os.environ.get('MESHROOM_DEFAULT_PIPELINE', 'photogrammetry'), help='Template pipeline among those listed or a Meshroom file containing a custom pipeline ' 'to run on input images:\n' + '\n'.join([' - ' + p for p in meshroom.core.pipelineTemplates])) general_group.add_argument( '-o', '--output', metavar='FOLDER COPYFILES_INSTANCE=FOLDER', type=str, required=False, nargs='*', help='Output folder for copying results. ' 'Sets output folder for all CopyFiles nodes, or target specific nodes using COPYFILES_INSTANCE=FOLDER.') general_group.add_argument( '-s', '--save', metavar='FILE', type=str, required=False, help='Save the configured Meshroom graph to a project file. It will setup the cache folder accordingly. ') general_group.add_argument( '--submit', help='Submit on renderfarm instead of local computation.', action='store_true') general_group.add_argument( '-v', '--verbose', help='Set the verbosity level for logging:\n' ' - fatal: Show only critical errors.\n' ' - error: Show errors only.\n' ' - warning: Show warnings and errors.\n' ' - info: Show standard informational messages.\n' ' - debug: Show detailed debug information.\n' ' - trace: Show all messages, including trace-level details.', default=os.environ.get('MESHROOM_VERBOSE', 'warning'), choices=['fatal', 'error', 'warning', 'info', 'debug', 'trace']) advanced_group = parser.add_argument_group('Advanced Options') advanced_group.add_argument( '--overrides', metavar='SETTINGS', type=str, default=None, help='A JSON file containing the graph parameters override.') advanced_group.add_argument( '--paramOverrides', metavar='NODETYPE:param=value NODEINSTANCE.param=value', type=str, default=None, nargs='*', help='Override specific parameters directly from the command line (by node type or by node names).') advanced_group.add_argument( '--compute', metavar='', type=lambda x: bool(strtobool(x)), default=True, required=False, help='You can set it to to disable the computation.') advanced_group.add_argument( '--toNode', metavar='NODE', type=str, nargs='*', default=None, help='Process the node(s) with its dependencies.') advanced_group.add_argument( '--forceStatus', help='Force computation if status is RUNNING or SUBMITTED.', action='store_true') advanced_group.add_argument( '--forceCompute', help='Compute in all cases even if already computed.', action='store_true') advanced_group.add_argument( "--submitLabel", type=str, default=os.environ.get('MESHROOM_SUBMIT_LABEL', '[Meshroom] {projectName}'), help="Label of a node when submitted on renderfarm.") advanced_group.add_argument( '--submitter', type=str, default='Tractor', help='Execute job with a specific submitter.') args = parser.parse_args() logging.getLogger().setLevel(logStringToPython[args.verbose]) meshroom.core.initPlugins() meshroom.core.initNodes() graph = meshroom.core.graph.Graph(name=args.pipeline) with meshroom.core.graph.GraphModification(graph): # initialize template pipeline loweredPipelineTemplates = {k.lower(): v for k, v in meshroom.core.pipelineTemplates.items()} if args.pipeline.lower() in loweredPipelineTemplates: graph.initFromTemplate(loweredPipelineTemplates[args.pipeline.lower()], copyOutputs=True if args.output else False) else: # custom pipeline graph.initFromTemplate(args.pipeline, copyOutputs=True if args.output else False) if args.input: # get init nodes initNodes = graph.findInitNodes() initNodesNames = [n.getName() for n in initNodes] # parse inputs for each init node mapInput = parseInitInputs(args.input) # parse recursive inputs for each init node mapInputRecursive = parseInitInputs(args.inputRecursive) # check that input nodes exist in the pipeline template for nodeName in mapInput.keys() | mapInputRecursive.keys(): if nodeName and nodeName not in initNodesNames: raise RuntimeError(f"Failed to find the Init Node '{nodeName}' in your pipeline.\nAvailable Init Nodes: {initNodesNames}") # feed inputs (recursive and non-recursive paths) to corresponding init nodes for initNode in initNodes: nodeName = initNode.getName() if nodeName not in mapInput | mapInputRecursive and \ "" not in mapInput | mapInputRecursive: continue # Retrieve input per node and inputs for all init node types input = mapInput.get(nodeName, []) + mapInput.get("", []) # Retrieve recursive inputs inputRec = mapInputRecursive.get(nodeName, []) + mapInputRecursive.get("", []) initNode.nodeDesc.initialize(initNode, input, inputRec) if not graph.canComputeLeaves: raise RuntimeError("Graph cannot be computed. Check for compatibility issues.") if args.verbose: graph.setVerbose(args.verbose) if args.output: # The output folders for CopyFiles nodes can be set as follows: # - for each node, the output folder is specified following the # "CopyFiles_name=/output/folder/path" convention. # - a path is provided without specifying which CopyFiles node should be set with it: # all the CopyFiles nodes will be set with it. # - some CopyFiles nodes have their path specified, and another path is provided # without specifying a node: all CopyFiles nodes with dedicated will have their own # output folders set, and those which have not been specified will be set with the # other path. # - some CopyFiles nodes have their output folder specified while others do not: all # the nodes with specified folders will use the provided values, and those without # any will be set with the output folder of the first specified CopyFiles node. # - several output folders are provided without specifying any node: the last one will # be used to set all the CopyFiles nodes' output folders. # Check that there is at least one CopyFiles node copyNodes = graph.nodesOfType('CopyFiles') if len(copyNodes) == 0: raise RuntimeError('meshroom_batch requires a pipeline graph with at least ' + 'one CopyFiles node, none found.') reExtract = re.compile(r'(\w+)=(.*)') # NodeName=value globalCopyPath = '' for p in args.output: result = reExtract.match(p) if not result: # If the argument is only a path, set it for the global path globalCopyPath = p continue node, value = result.groups() for i, n in enumerate(copyNodes): # Find the correct CopyFiles node in the list if n.name == node: # If found, set the value, and remove it from the list n.output.value = value copyNodes.pop(i) if globalCopyPath == '': # Fallback in case some nodes would have no path globalCopyPath = value break for n in copyNodes: # Set the remaining CopyPath nodes with the global path n.output.value = globalCopyPath else: print(f'No output set, results will be available in the cache folder: "{graph.cacheDir}"') if args.overrides: with open(args.overrides, encoding='utf-8', errors='ignore') as f: data = json.load(f) for nodeName, overrides in data.items(): for attrName, value in overrides.items(): graph.findNode(nodeName).attribute(attrName).value = value if args.paramOverrides: print("\n") reExtract = re.compile(r'(\w+)([:.])(\w[\w.]*)=(.*)') for p in args.paramOverrides: result = reExtract.match(p) if not result: raise ValueError('Invalid param override: ' + str(p)) node, t, param, value = result.groups() if t == ':': nodesOfType = graph.nodesOfType(node) if not nodesOfType: raise ValueError(f'No node with the type "{node}" in the scene.') for n in nodesOfType: print(f'Overrides {node}.{param}={value}') n.attribute(param).value = value elif t == '.': print(f'Overrides {node}.{param}={value}') graph.findNode(node).attribute(param).value = value else: raise ValueError('Invalid param override: ' + str(p)) print("\n") if args.save: graph.save(args.save) print(f'File successfully saved: "{args.save}"') # find end nodes (None will compute all graph) toNodes = graph.findNodes(args.toNode) if args.toNode else None if args.submit: meshroom.core.initSubmitters() if not args.save: raise ValueError('Need to save the project to file to submit on renderfarm.') # submit on renderfarm meshroom.core.graph.submit(args.save, args.submitter, toNode=args.toNode, submitLabel=args.submitLabel) elif args.compute: # find end nodes (None will compute all graph) toNodes = graph.findNodes(args.toNode) if args.toNode else None # start computation meshroom.core.graph.executeGraph(graph, toNodes=toNodes, forceCompute=args.forceCompute, forceStatus=args.forceStatus) ================================================ FILE: bin/meshroom_compute ================================================ #!/usr/bin/env python import argparse import logging import os import sys from typing import NoReturn try: import meshroom except Exception: # If meshroom module is not in the PYTHONPATH, add our root using the relative path import pathlib meshroomRootFolder = pathlib.Path(__file__).parent.parent.resolve() sys.path.append(meshroomRootFolder) import meshroom meshroom.setupEnvironment() import meshroom.core import meshroom.core.graph from meshroom.core.node import Status parser = argparse.ArgumentParser(description='Execute a Graph of processes.') parser.add_argument('graphFile', metavar='GRAPHFILE.mg', type=str, help='Filepath to a graph file.') parser.add_argument('--node', metavar='NODE_NAME', type=str, help='Process the node. It will generate an error if the dependencies are not already computed.') parser.add_argument('--toNode', metavar='NODE_NAME', type=str, help='Process the node with its dependencies.') parser.add_argument('--inCurrentEnv', help='Execute process in current env without creating a dedicated runtime environment.', action='store_true') parser.add_argument('--forceStatus', help='Force computation if status is RUNNING or SUBMITTED.', action='store_true') parser.add_argument('--forceCompute', help='Compute in all cases even if already computed.', action='store_true') parser.add_argument('--extern', help='Use this option when you compute externally after submission to a render farm from meshroom.', action='store_true') parser.add_argument('--cache', metavar='FOLDER', type=str, default=None, help='Override the cache folder') parser.add_argument('-v', '--verbose', help='Set the verbosity level for logging:\n' ' - fatal: Show only critical errors.\n' ' - error: Show errors only.\n' ' - warning: Show warnings and errors.\n' ' - info: Show standard informational messages.\n' ' - debug: Show detailed debug information.\n' ' - trace: Show all messages, including trace-level details.', default=os.environ.get('MESHROOM_VERBOSE', 'info'), choices=['fatal', 'error', 'warning', 'info', 'debug', 'trace']) parser.add_argument('-i', '--iteration', type=int, default=-1, help='') args = parser.parse_args() # Setup the verbose level if args.extern: # For extern computation, we want to focus on the node computation log. # So, we avoid polluting the log with general warning about plugins, versions of nodes in file, etc. logging.getLogger().setLevel(level=logging.ERROR) else: logging.getLogger().setLevel(meshroom.logStringToPython[args.verbose]) meshroom.core.initPlugins() meshroom.core.initNodes() meshroom.core.initSubmitters() graph = meshroom.core.graph.loadGraph(args.graphFile) if args.cache: graph.cacheDir = args.cache graph.update() def killRunningJob(node) -> NoReturn: """ Kills current job and try to avoid job restarting """ jobInfo = node.nodeStatus.jobInfo submitterName = jobInfo.get("submitterName") if not submitterName: sys.exit(meshroom.MeshroomExitStatus.ERROR_NO_RETRY) from meshroom.core import submitters for subName, sub in submitters.items(): if submitterName == subName: sub.killRunningJob() break sys.exit(meshroom.MeshroomExitStatus.ERROR_NO_RETRY) if args.node: node = graph.findNode(args.node) node.updateStatusFromCache() submittedStatuses = [Status.RUNNING] if node.isCompatibilityNode: print(f'{node.name} is in Compatibility Mode and cannot be computed.') print(f'Compatibility issue: {node.issueDetails}') sys.exit(1) # Execute the node if not args.extern: # If running as "extern", the task is supposed to have the status SUBMITTED. # If not running as "extern", the SUBMITTED status should generate a warning. submittedStatuses.append(Status.SUBMITTED) if args.iteration >= 0 and not node._chunksCreated: print(f"Error: Computing chunk {args.iteration} of node {node} before chunks have been created. " \ f"See file: \"{node.nodeStatusFile}\".") sys.exit(-1) if node.isInputNode: print(f"InputNode: No computation to do.") sys.exit(0) if not args.forceStatus and not args.forceCompute: if args.iteration != -1: chunks = [node.chunks[args.iteration]] else: chunks = node.chunks for chunk in chunks: if chunk.status.status in submittedStatuses: # Particular case for the local isolated, the node status is set to RUNNING by the submitter directly. # We ensure that no other instance has started to compute, by checking that the computeSessionUid is empty. if chunk.node.getMrNodeType() == meshroom.core.MrNodeType.NODE and \ not chunk.status.computeSessionUid and node._nodeStatus.submitterSessionUid: continue print(f'Warning: Node is already submitted with status "{chunk.status.status.name}". See file: "{chunk.statusFile}". ExecMode: {chunk.status.execMode.name}, computeSessionUid: {chunk.status.computeSessionUid}, submitterSessionUid: {node._nodeStatus.submitterSessionUid}') # sys.exit(-1) if args.extern: # Restore the log level logging.getLogger().setLevel(meshroom.logStringToPython[args.verbose]) node.prepareLogger(args.iteration) node.preprocess() if args.iteration != -1: chunk = node.chunks[args.iteration] if chunk._status.status == Status.STOPPED: print(f"Chunk {chunk}: status is STOPPED") killRunningJob(node) chunk.process(args.forceCompute, args.inCurrentEnv) else: if node.nodeStatus.status == Status.STOPPED: print(f"Node {node}: status is STOPPED") killRunningJob(node) node.createChunks() node.process(args.forceCompute, args.inCurrentEnv) node.postprocess() node.restoreLogger() else: if args.iteration != -1: print('Error: "--iteration" only makes sense when used with "--node".') sys.exit(-1) toNodes = None if args.toNode: toNodes = graph.findNodes([args.toNode]) meshroom.core.graph.executeGraph(graph, toNodes=toNodes, forceCompute=args.forceCompute, forceStatus=args.forceStatus) ================================================ FILE: bin/meshroom_createChunks ================================================ #!/usr/bin/env python """ This is a script used to wrap the process of processing a node on the farm It will handle chunk creation and create all the jobs for these chunks If the submitter cannot create chunks, then it will process the chunks serially in the current process """ import argparse import logging import os import sys try: import meshroom except Exception: # If meshroom module is not in the PYTHONPATH, add our root using the relative path import pathlib meshroomRootFolder = pathlib.Path(__file__).parent.parent.resolve() sys.path.append(meshroomRootFolder) import meshroom meshroom.setupEnvironment() import meshroom.core import meshroom.core.graph from meshroom.core import submitters from meshroom.core.submitter import SubmitterOptionsEnum from meshroom.core.node import Status parser = argparse.ArgumentParser(description='Execute a Graph of processes.') parser.add_argument('graphFile', metavar='GRAPHFILE.mg', type=str, help='Filepath to a graph file.') parser.add_argument('--submitter', type=str, required=True, help='Name of the submitter used to create the job.') parser.add_argument('--node', metavar='NODE_NAME', type=str, required=True, help='Process the node. It will generate an error if the dependencies are not already computed.') parser.add_argument('--inCurrentEnv', help='Execute process in current env without creating a dedicated runtime environment.', action='store_true') parser.add_argument('--forceStatus', help='Force computation if status is RUNNING or SUBMITTED.', action='store_true') parser.add_argument('--forceCompute', help='Compute in all cases even if already computed.', action='store_true') parser.add_argument('--extern', help='Use this option when you compute externally after submission to a render farm from meshroom.', action='store_true') parser.add_argument('--cache', metavar='FOLDER', type=str, default=None, help='Override the cache folder') parser.add_argument('-v', '--verbose', help='Set the verbosity level for logging:\n' ' - fatal: Show only critical errors.\n' ' - error: Show errors only.\n' ' - warning: Show warnings and errors.\n' ' - info: Show standard informational messages.\n' ' - debug: Show detailed debug information.\n' ' - trace: Show all messages, including trace-level details.', default=os.environ.get('MESHROOM_VERBOSE', 'info'), choices=['fatal', 'error', 'warning', 'info', 'debug', 'trace']) args = parser.parse_args() # For extern computation, we want to focus on the node computation log. # So, we avoid polluting the log with general warning about plugins, versions of nodes in file, etc. logging.getLogger().setLevel(level=logging.INFO) meshroom.core.initPlugins() meshroom.core.initNodes() meshroom.core.initSubmitters() # Required to spool child job graph = meshroom.core.graph.loadGraph(args.graphFile) if args.cache: graph.cacheDir = args.cache graph.update() # Execute the node node = graph.findNode(args.node) submittedStatuses = [Status.RUNNING] # Find submitter submitter = None # It's required if we want to spool chunks on different machines for subName, sub in submitters.items(): if args.submitter == subName: submitter = sub break if node._nodeStatus.status in (Status.STOPPED, Status.KILLED): logging.error("Node status is STOPPED or KILLED.") if submitter: submitter.killRunningJob() sys.exit(meshroom.MeshroomExitStatus.ERROR_NO_RETRY) if not node._chunksCreated: # Create node chunks # Once created we don't have to do it again even if we relaunch the job node.createChunks() # Set the chunks statuses for chunk in node._chunks: if args.forceCompute or chunk._status.status != Status.SUCCESS: hasChunkToLaunch = True chunk._status.setNode(node) chunk._status.initExternSubmit() chunk.upgradeStatusFile() # Get chunks to process in the current process chunksToProcess = [] if submitter: if not submitter._options.includes(SubmitterOptionsEnum.EDIT_TASKS): chunksToProcess = node.chunks else: # Cannot retrieve job -> execute process serially chunksToProcess = node.chunks logging.info(f"[MeshroomCreateChunks] Chunks to process here : {chunksToProcess}") if not args.forceStatus and not args.forceCompute: for chunk in chunksToProcess: if chunk.status.status in submittedStatuses: # Particular case for the local isolated, the node status is set to RUNNING by the submitter directly. # We ensure that no other instance has started to compute, by checking that the sessicomputeSessionUidonUid is empty. if chunk.node.getMrNodeType() == meshroom.core.MrNodeType.NODE and \ not chunk.status.computeSessionUid and node._nodeStatus.submitterSessionUid: continue logging.warning( f"[MeshroomCreateChunks] Node is already submitted with status " \ f"\"{chunk.status.status.name}\". See file: \"{chunk.statusFile}\". " \ f"ExecMode: {chunk.status.execMode.name}, computeSessionUid: {chunk.status.computeSessionUid}, " \ f"submitterSessionUid: {node._nodeStatus.submitterSessionUid}") if chunksToProcess: node.prepareLogger() node.preprocess() for chunk in chunksToProcess: logging.info(f"[MeshroomCreateChunks] process chunk {chunk}") chunk.process(args.forceCompute, args.inCurrentEnv) node.postprocess() node.restoreLogger() else: logging.info(f"[MeshroomCreateChunks] -> create job to process chunks {[c for c in node.chunks]}") submitter.createChunkTask(node, graphFile=args.graphFile, cache=args.cache, forceStatus=args.forceStatus, forceCompute=args.forceCompute) # Restore the log level logging.getLogger().setLevel(meshroom.logStringToPython[args.verbose]) ================================================ FILE: bin/meshroom_newNodeType ================================================ #!/usr/bin/env python import argparse import os import re import sys import shlex from pprint import pprint def trim(s): """ All repetition of any kind of space is replaced by a single space and remove trailing space at beginning or end. """ # regex to replace all space groups by a single space # use split() to remove trailing space at beginning/end return re.sub(r'\s+', ' ', s).strip() def quotesForStrings(valueStr): """ Return the input string with quotes if it cannot be cast into another builtin type. """ v = valueStr try: int(valueStr) except ValueError: try: float(valueStr) except ValueError: if "'" in valueStr: v = f"'''{valueStr}'''" else: v = f"'{valueStr}'" return v def convertToLabel(name): camelCaseToLabel = re.sub('()([A-Z][a-z]*?)', r'\1 \2', name) snakeToLabel = ' '.join(word.capitalize() for word in camelCaseToLabel.split('_')) snakeToLabel = ' '.join(word.capitalize() for word in snakeToLabel.split(' ')) return snakeToLabel def is_int(s): try: int(s) return True except ValueError: return False def is_float(s): try: float(s) return True except ValueError: return False parser = argparse.ArgumentParser(description='Create a new Node Type') parser.add_argument('node', metavar='NODE_NAME', type=str, help='New node name') parser.add_argument('bin', metavar='CMDLINE', type=str, default=None, help='Input executable') parser.add_argument('--output', metavar='DIR', type=str, default=os.path.dirname(__file__), help='Output plugin folder') parser.add_argument('--parser', metavar='PARSER', type=str, default='boost', help='Select the parser adapted for your command line: {boost,cmdLineLib,basic}.') parser.add_argument("--force", help="Allows to overwrite the output plugin file.", action="store_true") args = parser.parse_args() inputCmdLineDoc = None soft = "{nodeType}" if args.bin: soft = args.bin import subprocess proc = subprocess.Popen(args=shlex.split(args.bin) + ['--help'], stdout=subprocess.PIPE, stderr=subprocess.PIPE) stdout, stderr = proc.communicate() inputCmdLineDoc = stdout if stdout else stderr elif sys.stdin.isatty(): inputCmdLineDoc = ''.join([line for line in sys.stdin]) if not inputCmdLineDoc: print('No input documentation.') print(f'Usage: YOUR_COMMAND --help | {os.path.splitext(__file__)[0]}') sys.exit(-1) fileStr = '''import sys from meshroom.core import desc class __COMMANDNAME__(desc.CommandLineNode): commandLine = '__SOFT__ {allParams}' '''.replace('__COMMANDNAME__', args.node).replace('__SOFT__', soft) print(inputCmdLineDoc) args_re = None if args.parser == 'boost': args_re = re.compile( r'^\s+' # space(s) r'(?:-(?P\w+)\|?)?' # potential argument short name r'\s*\[?' # potential '[' r'\s*--(?P\w+)' # argument long name r'(?:\s*\])?' # potential ']' r'(?:\s+(?P\w+)?)?' # potential arg r'(?:\s+\(\=(?P.+)\))?' # potential default value r'\s+(?P.*?)\n' # end of the line r'(?P(?:\s+[^-\s].+?\n)*)' # next documentation lines , re.MULTILINE) elif args.parser == 'cmdLineLib': args_re = re.compile( '^' r'\[' # '[' r'-(?P\w+)' # argument short name r'\|' r'--(?P\w+)' # argument long name r'(?:\s+(?P\w+)?)?' # potential arg r'\]' # ']' r'()' # no default value r'(?P.*?)?\n' # end of the line r'(?P(?:[^\[\w].+?\n)*)' # next documentation lines , re.MULTILINE) elif args.parser == 'basic': args_re = re.compile(r'()--(?P\w+)()()()()') else: print(f'Error: Unknown input parser "{args.parser}"') sys.exit(-1) choiceValues1_re = re.compile(r'\* (?P\w+):') choiceValues2_re = re.compile(r'\((?P.+?)\)') choiceValues3_re = re.compile(r'\{(?P.+?)\}') cmdLineArgs = args_re.findall(inputCmdLineDoc.decode('utf-8')) print('='*80) pprint(cmdLineArgs) outputNodeStr = '' inputNodeStr = '' for cmdLineArg in cmdLineArgs: shortName = cmdLineArg[0] longName = cmdLineArg[1] if longName == 'help': continue # skip help argument arg = cmdLineArg[2] value = cmdLineArg[3] descLines = cmdLineArg[4:] description = ''.join(descLines).strip() if description.endswith(':'): # If documentation is multiple lines and the last line ends with ':', # we remove this last line as it is probably the title of the next group of options description = '\n'.join(description.split('\n')[:-1]) description = trim(description) values = choiceValues1_re.findall(description) if not values: possibleLists = choiceValues2_re.findall(description) + choiceValues3_re.findall(description) for possibleList in possibleLists: candidate = possibleList.split(',') if len(candidate) > 1: values = [trim(v) for v in candidate] cmdLineArgLower = ' '.join([shortName, longName, arg, value, description]).lower() namesLower = ' '.join([shortName, longName]).lower() isBool = (arg == '' and value == '') isFile = 'path' in cmdLineArgLower or 'folder' in cmdLineArgLower or 'file' in cmdLineArgLower isChoice = bool(values) isOutput = 'output' in cmdLineArgLower or 'out' in namesLower isInt = is_int(value) isFloat = is_float(value) argStr = None if isBool: argStr = """ desc.BoolParam( name='{name}', label='{label}', description='''{description}''', value={value}, ),""".format( name=longName, label=convertToLabel(longName), description=description, value=quotesForStrings(value), arg=arg, ) elif isFile: argStr = """ desc.File( name='{name}', label='{label}', description='''{description}''', value={value}, ),""".format( name=longName, label=convertToLabel(longName), description=description, value=quotesForStrings(value), arg=arg, ) elif isChoice: argStr = """ desc.ChoiceParam( name='{name}', label='{label}', description='''{description}''', value={value}, values={values}, exclusive={exclusive}, ),""".format( name=longName, label=convertToLabel(longName), description=description, value=quotesForStrings(value), values=values, exclusive=True, ) elif isInt: argStr = """ desc.IntParam( name='{name}', label='{label}', description='''{description}''', value={value}, range={range}, ),""".format( name=longName, label=convertToLabel(longName), description=description, value=value, range='(-sys.maxsize, sys.maxsize, 1)', ) elif isFloat: argStr = """ desc.FloatParam( name='{name}', label='{label}', description='''{description}''', value={value}, range={range}, ),""".format( name=longName, label=convertToLabel(longName), description=description, value=value, range='''(-float('inf'), float('inf'), 0.01)''', ) else: argStr = """ desc.StringParam( name='{name}', label='{label}', description='''{description}''', value={value}, ),""".format( name=longName, label=convertToLabel(longName), description=description, value=quotesForStrings(value), range=range, ) if isOutput: outputNodeStr += argStr else: inputNodeStr += argStr fileStr += """ inputs = [""" + inputNodeStr + """ ] outputs = [""" + outputNodeStr + """ ] """ outputFilepath = os.path.join(args.output, args.node + '.py') if not args.force and os.path.exists(outputFilepath): print(f'Plugin "{args.node}" already exists "{outputFilepath}".') sys.exit(-1) with open(outputFilepath, 'w') as pluginFile: pluginFile.write(fileStr) print(f'New node exported to: "{outputFilepath}"') ================================================ FILE: bin/meshroom_statistics ================================================ #!/usr/bin/env python import argparse import os import sys import logging from collections import defaultdict from collections.abc import Iterable import meshroom from meshroom.core import graph as pg def addPlots(curves, title, fileObj): if not curves: return import matplotlib.pyplot as plt, mpld3 fig = plt.figure() ax = fig.add_subplot(111, facecolor='#EEEEEE') ax.grid(color='white', linestyle='solid') for curveName, curve in curves: if not isinstance(curve[0], str): ax.plot(curve, label=curveName) ax.legend() # plt.ylim(0, 100) plt.title(title) mpld3.save_html(fig, fileObj) plt.close(fig) parser = argparse.ArgumentParser(description='Query the status of nodes in a Graph of processes.') parser.add_argument('graphFile', metavar='GRAPHFILE.mg', type=str, help='Filepath to a graph file.') parser.add_argument('--node', metavar='NODE_NAME', type=str, help='Process the node alone.') parser.add_argument('--graph', metavar='NODE_NAME', type=str, help='Process the node and all previous nodes needed.') parser.add_argument('--exportHtml', metavar='FILE', type=str, help='Filepath to the output html file.') parser.add_argument('-v', '--verbose', help='Set the verbosity level for logging:\n' ' - fatal: Show only critical errors.\n' ' - error: Show errors only.\n' ' - warning: Show warnings and errors.\n' ' - info: Show standard informational messages.\n' ' - debug: Show detailed debug information.\n' ' - trace: Show all messages, including trace-level details.', default=os.environ.get('MESHROOM_VERBOSE', 'info'), choices=['fatal', 'error', 'warning', 'info', 'debug', 'trace']) args = parser.parse_args() if not os.path.exists(args.graphFile): print(f'ERROR: No graph file "{args.graphFile}".') sys.exit(-1) logging.getLogger().setLevel(meshroom.logStringToPython[args.verbose]) meshroom.core.initPlugins() meshroom.core.initNodes() graph = pg.loadGraph(args.graphFile) graph.update() graph.updateStatisticsFromCache() nodes = [] if args.node: nodes = [graph.findNode(args.node)] else: startNodes = None if args.graph: startNodes = [graph.node(args.graph)] nodes, edges = graph.dfsOnFinish(startNodes=startNodes) for node in nodes: for chunk in node.chunks: print(f'{chunk.name}: {chunk.statistics.toDict()}\n') if args.exportHtml: with open(args.exportHtml, 'w') as fileObj: for node in nodes: for chunk in node.chunks: for curves in (chunk.statistics.computer.curves, chunk.statistics.process.curves): exportCurves = defaultdict(list) for name, curve in curves.items(): s = name.split('.') figName = s[0] curveName = ''.join(s[1:]) exportCurves[figName].append((curveName, curve)) for name, curves in exportCurves.items(): addPlots(curves, name, fileObj) ================================================ FILE: bin/meshroom_status ================================================ #!/usr/bin/env python import argparse import os import sys import pprint import logging import meshroom meshroom.setupEnvironment() import meshroom.core.graph parser = argparse.ArgumentParser(description='Query the status of nodes in a Graph of processes.') parser.add_argument('graphFile', metavar='GRAPHFILE.mg', type=str, help='Filepath to a graph file.') parser.add_argument('--node', metavar='NODE_NAME', type=str, help='Process the node alone.') parser.add_argument('--toNode', metavar='NODE_NAME', type=str, help='Process the node and all previous nodes needed.') parser.add_argument('-v', '--verbose', help='Set the verbosity level for logging:\n' ' - fatal: Show only critical errors.\n' ' - error: Show errors only.\n' ' - warning: Show warnings and errors.\n' ' - info: Show standard informational messages.\n' ' - debug: Show detailed debug information.\n' ' - trace: Show all messages, including trace-level details.', default=os.environ.get('MESHROOM_VERBOSE', 'info'), choices=['fatal', 'error', 'warning', 'info', 'debug', 'trace']) args = parser.parse_args() if not os.path.exists(args.graphFile): print(f'ERROR: No graph file "{args.node}".') sys.exit(-1) logging.getLogger().setLevel(meshroom.logStringToPython[args.verbose]) meshroom.core.initPlugins() meshroom.core.initNodes() graph = meshroom.core.graph.loadGraph(args.graphFile) graph.update() if args.node: node = graph.node(args.node) if node is None: logging.error(f'Node "{args.node}" does not exist in file "{args.graphFile}".') sys.exit(-1) for chunk in node.chunks: print(f'{chunk.name}: {chunk.status.status.name}') logging.debug(f'nodeStatusFile: {node.nodeStatusFile}') logging.debug(pprint.pformat(node.nodeStatus.toDict())) else: startNodes = None if args.toNode: startNodes = [graph.findNode(args.toNode)] nodes, edges = graph.dfsOnFinish(startNodes=startNodes) for node in nodes: for chunk in node.chunks: print(f'{chunk.name}: {chunk.nodeStatus.status.name}') logging.debug(pprint.pformat([n.status.toDict() for n in nodes])) ================================================ FILE: bin/meshroom_submit ================================================ #!/usr/bin/env python import argparse import meshroom meshroom.setupEnvironment() import meshroom.core.graph parser = argparse.ArgumentParser(description='Submit a Graph of processes on renderfarm.') parser.add_argument('meshroomFile', metavar='MESHROOMFILE.mg', type=str, help='Filepath to a graph file.') parser.add_argument('--toNode', metavar='NODE_NAME', type=str, help='Process the node with its dependencies.') parser.add_argument('--submitter', type=str, default='Tractor', help='Execute job with a specific submitter.') parser.add_argument("--submitLabel", type=str, default='[Meshroom] {projectName}', help="Label of a node in the submitter") args = parser.parse_args() meshroom.core.initPlugins() meshroom.core.initNodes() meshroom.core.initSubmitters() meshroom.core.graph.submit(args.meshroomFile, args.submitter, toNode=args.toNode, submitLabel=args.submitLabel) ================================================ FILE: dev_requirements.txt ================================================ # packaging cx_Freeze==7.2.10 # Python binding packaging numpy==1.* # testing pytest ================================================ FILE: docker/Dockerfile_rocky ================================================ ARG MESHROOM_VERSION ARG AV_VERSION ARG CUDA_VERSION ARG ROCKY_VERSION FROM alicevision/meshroom-deps:${MESHROOM_VERSION}-av${AV_VERSION}-rocky${ROCKY_VERSION}-cuda${CUDA_VERSION} LABEL maintainer="AliceVision Team alicevision-team@googlegroups.com" # Execute with nvidia docker (https://github.com/nvidia/nvidia-docker/wiki/Installation-(version-2.0)) # docker run -it --runtime nvidia -p 2222:22 --name meshroom -v:/data alicevision/meshroom:develop-av2.2.8.develop-ubuntu20.04-cuda11.0 # ssh -p 2222 -X root@ /opt/Meshroom_bundle/Meshroom # Password is 'meshroom' RUN dnf install -y patchelf ENV MESHROOM_DEV=/opt/Meshroom \ MESHROOM_BUILD=/tmp/Meshroom_build \ MESHROOM_BUNDLE=/opt/Meshroom_bundle \ AV_INSTALL=/opt/AliceVision_install \ QT_DIR=/opt/Qt/6.8.3/gcc_64 \ PATH="${PATH}:${MESHROOM_BUNDLE}" COPY *.txt *.md *.py ${MESHROOM_DEV}/ COPY ./docs ${MESHROOM_DEV}/docs COPY ./meshroom ${MESHROOM_DEV}/meshroom COPY ./tests ${MESHROOM_DEV}/tests COPY ./bin ${MESHROOM_DEV}/bin WORKDIR ${MESHROOM_DEV} # Generate the exe for Meshroom and clean-up the bundle folder RUN python setup.py install_exe -d "${MESHROOM_BUNDLE}" && \ find ${MESHROOM_BUNDLE} -name "*Qt6Web*" -delete && \ find ${MESHROOM_BUNDLE} -name "*Qt6Designer*" -delete && \ rm -rf ${MESHROOM_BUNDLE}/lib/PySide6/typesystems/ \ ${MESHROOM_BUNDLE}/lib/PySide6/examples/ \ ${MESHROOM_BUNDLE}/lib/PySide6/include/ \ ${MESHROOM_BUNDLE}/lib/PySide6/Qt/translations/ \ ${MESHROOM_BUNDLE}/lib/PySide6/Qt/resources/ \ ${MESHROOM_BUNDLE}/lib/PySide6/QtWeb* \ ${MESHROOM_BUNDLE}/lib/PySide6/rcc \ ${MESHROOM_BUNDLE}/lib/PySide6/designer WORKDIR ${MESHROOM_BUILD} # Move the bundled installation of AliceVision into Meshroom's bundle RUN mkdir ${MESHROOM_BUNDLE}/aliceVision && \ mv /opt/AliceVision_bundle/* ${MESHROOM_BUNDLE}/aliceVision # Build Meshroom plugins RUN cmake "${MESHROOM_DEV}" -DALICEVISION_ROOT="${AV_INSTALL}" -DCMAKE_INSTALL_PREFIX="${MESHROOM_BUNDLE}/qtPlugins" RUN make "-j$(nproc)" QtAliceVision RUN make "-j$(nproc)" && \ rm -rf "${MESHROOM_BUILD}" "${MESHROOM_DEV}" \ ${MESHROOM_BUNDLE}/aliceVision/share/doc \ ${MESHROOM_BUNDLE}/aliceVision/share/eigen3 \ ${MESHROOM_BUNDLE}/aliceVision/share/fonts \ ${MESHROOM_BUNDLE}/aliceVision/share/lemon \ ${MESHROOM_BUNDLE}/aliceVision/share/libraw \ ${MESHROOM_BUNDLE}/aliceVision/share/man/ \ aliceVision/share/pkgconfig # PySide6: copy missing libQt63DQuickScene3D.so along with its dependencies to avoid runtime issues RUN cp ${QT_DIR}/lib/libQt63DQuickScene3D.so.6.8.3 ${MESHROOM_BUNDLE}/lib/PySide6/Qt/qml/QtQuick/Dialogs && \ mv ${MESHROOM_BUNDLE}/lib/PySide6/Qt/qml/QtQuick/Dialogs/libQt63DQuickScene3D.so.6.8.3 ${MESHROOM_BUNDLE}/lib/PySide6/Qt/qml/QtQuick/Dialogs/libQt63DQuickScene3D.so.6 && \ cp ${MESHROOM_BUNDLE}/lib/PySide6/Qt/lib/libQt6Concurrent.so.6 ${MESHROOM_BUNDLE}/lib/PySide6/Qt/qml/QtQuick/Dialogs && \ cp ${MESHROOM_BUNDLE}/lib/PySide6/Qt/qml/Qt3D/Animation/libQt63DAnimation.so.6 ${MESHROOM_BUNDLE}/lib/PySide6/Qt/qml/QtQuick/Dialogs && \ cp ${MESHROOM_BUNDLE}/lib/PySide6/Qt/qml/Qt3D/Core/libQt63DCore.so.6 ${MESHROOM_BUNDLE}/lib/PySide6/Qt/qml/QtQuick/Dialogs && \ cp ${MESHROOM_BUNDLE}/lib/PySide6/Qt/qml/Qt3D/Input/libQt63DInput.so.6 ${MESHROOM_BUNDLE}/lib/PySide6/Qt/qml/QtQuick/Dialogs && \ cp ${MESHROOM_BUNDLE}/lib/PySide6/Qt/qml/Qt3D/Logic/libQt63DLogic.so.6 ${MESHROOM_BUNDLE}/lib/PySide6/Qt/qml/QtQuick/Dialogs && \ cp ${MESHROOM_BUNDLE}/lib/PySide6/Qt/qml/Qt3D/Render/libQt63DRender.so.6 ${MESHROOM_BUNDLE}/lib/PySide6/Qt/qml/QtQuick/Dialogs # Copy libOpenGL in the bundle: needed by QtAliceVision as a side effect of a Qt6 bug RUN cp /usr/lib64/libOpenGL.so.0.0.0 ${MESHROOM_BUNDLE}/lib RUN mv ${MESHROOM_BUNDLE}/lib/libOpenGL.so.0.0.0 ${MESHROOM_BUNDLE}/lib/libOpenGL.so.0 # Enable SSH X11 forwarding, needed when the Docker image # is run on a remote machine RUN dnf install -y openssh openssh-clients openssh-server xorg-x11-xauth RUN systemctl enable sshd && \ mkdir -p /run/sshd && \ ssh-keygen -A RUN sed -i "s/^.*X11Forwarding.*$/X11Forwarding yes/; s/^.*X11UseLocalhost.*$/X11UseLocalhost no/; s/^.*PermitRootLogin prohibit-password/PermitRootLogin yes/; s/^.*X11UseLocalhost.*/X11UseLocalhost no/;" /etc/ssh/sshd_config RUN echo "root:meshroom" | chpasswd WORKDIR /root EXPOSE 22 CMD ["/usr/sbin/sshd", "-D"] ================================================ FILE: docker/Dockerfile_rocky_deps ================================================ ARG AV_VERSION ARG CUDA_VERSION ARG ROCKY_VERSION FROM alicevision/alicevision:${AV_VERSION}-rocky${ROCKY_VERSION}-cuda${CUDA_VERSION} LABEL maintainer="AliceVision Team alicevision-team@googlegroups.com" # Execute with nvidia docker (https://github.com/nvidia/nvidia-docker/wiki/Installation-(version-2.0)) # docker run -it --runtime=nvidia meshroom ENV MESHROOM_DEV=/opt/Meshroom \ MESHROOM_BUILD=/tmp/Meshroom_build \ QT_DIR=/opt/Qt/6.8.3/gcc_64 \ QT_CI_LOGIN=alicevisionjunk@gmail.com \ QT_CI_P=azerty1. # Install libs needed by Qt RUN dnf update -y RUN dnf install -y flex fontconfig freetype glib2-devel libICE RUN dnf install -y libX11 libXext libXi libXrender libSM RUN dnf install -y libXt-devel mesa-libGLU-devel mesa-libOSMesa-devel mesa-libGL-devel mesa-libEGL-devel RUN dnf install -y zlib-devel systemd openssh-server RUN dnf install -y libxcb-devel \ libxkbcommon-devel \ libxkbcommon-x11-devel \ xcb-util-wm xcb-util-image \ xcb-util-keysyms \ xcb-util-renderutil RUN dnf install -y libglvnd-opengl # Install Qt (to build plugins) WORKDIR /tmp/qt COPY dl/qt.run /tmp/qt RUN chmod +x qt.run RUN ./qt.run --root /opt/Qt --verbose --email ${QT_CI_LOGIN} --password ${QT_CI_P} --accept-obligations \ --accept-licenses --default-answer --platform minimal --auto-answer installationErrorWithCancel=Ignore \ --no-force-installations --no-default-installations --confirm-command \ install qt.qt6.683.linux_gcc_64 qt.qt6.683.addons.qtcharts qt.qt6.683.addons.qt3d RUN rm qt.run # Strip sections containing ".note.ABI.tag" from .so: https://github.com/Microsoft/WSL/issues/3023 RUN find ${QT_DIR}/lib/ -name '*.so' | xargs strip --remove-section=.note.ABI-tag COPY ./*requirements.txt ${MESHROOM_DEV}/ # Install Meshroom requirements and freeze bundle WORKDIR "${MESHROOM_DEV}" RUN python -m pip install -r dev_requirements.txt -r requirements.txt ================================================ FILE: docker/Dockerfile_ubuntu ================================================ ARG MESHROOM_VERSION ARG AV_VERSION ARG CUDA_VERSION ARG UBUNTU_VERSION FROM alicevision/meshroom-deps:${MESHROOM_VERSION}-av${AV_VERSION}-ubuntu${UBUNTU_VERSION}-cuda${CUDA_VERSION} LABEL maintainer="AliceVision Team alicevision-team@googlegroups.com" # Execute with nvidia docker (https://github.com/nvidia/nvidia-docker/wiki/Installation-(version-2.0)) # docker run -it --runtime nvidia -p 2222:22 --name meshroom -v:/data alicevision/meshroom:develop-av2.2.8.develop-ubuntu20.04-cuda11.0 # ssh -p 2222 -X root@ /opt/Meshroom_bundle/Meshroom # Password is 'meshroom' ENV MESHROOM_DEV=/opt/Meshroom \ MESHROOM_BUILD=/tmp/Meshroom_build \ MESHROOM_BUNDLE=/opt/Meshroom_bundle \ AV_INSTALL=/opt/AliceVision_install \ QT_DIR=/opt/Qt/6.8.3/gcc_64 \ PATH="${PATH}:${MESHROOM_BUNDLE}" COPY *.txt *.md *.py ${MESHROOM_DEV}/ COPY ./docs ${MESHROOM_DEV}/docs COPY ./meshroom ${MESHROOM_DEV}/meshroom COPY ./tests ${MESHROOM_DEV}/tests COPY ./bin ${MESHROOM_DEV}/bin WORKDIR ${MESHROOM_DEV} RUN python setup.py install_exe -d "${MESHROOM_BUNDLE}" && \ find ${MESHROOM_BUNDLE} -name "*Qt6Web*" -delete && \ find ${MESHROOM_BUNDLE} -name "*Qt6Designer*" -delete && \ rm -rf ${MESHROOM_BUNDLE}/lib/PySide6/typesystems/ \ ${MESHROOM_BUNDLE}/lib/PySide6/examples/ \ ${MESHROOM_BUNDLE}/lib/PySide6/include/ \ ${MESHROOM_BUNDLE}/lib/PySide6/Qt/translations/ \ ${MESHROOM_BUNDLE}/lib/PySide6/Qt/resources/ \ ${MESHROOM_BUNDLE}/lib/PySide6/QtWeb* \ ${MESHROOM_BUNDLE}/lib/PySide6/rcc \ ${MESHROOM_BUNDLE}/lib/PySide6/designer WORKDIR ${MESHROOM_BUILD} # Move the bundled installation of AliceVision into Meshroom's bundle RUN mkdir ${MESHROOM_BUNDLE}/aliceVision && \ mv /opt/AliceVision_bundle/* ${MESHROOM_BUNDLE}/aliceVision # Build Meshroom plugins RUN cmake "${MESHROOM_DEV}" -DALICEVISION_ROOT="${AV_INSTALL}" -DCMAKE_INSTALL_PREFIX="${MESHROOM_BUNDLE}/qtPlugins" RUN make "-j$(nproc)" QtAliceVision RUN make "-j$(nproc)" && \ rm -rf "${MESHROOM_BUILD}" "${MESHROOM_DEV}" \ ${MESHROOM_BUNDLE}/aliceVision/share/doc \ ${MESHROOM_BUNDLE}/aliceVision/share/eigen3 \ ${MESHROOM_BUNDLE}/aliceVision/share/fonts \ ${MESHROOM_BUNDLE}/aliceVision/share/lemon \ ${MESHROOM_BUNDLE}/aliceVision/share/libraw \ ${MESHROOM_BUNDLE}/aliceVision/share/man/ \ aliceVision/share/pkgconfig # PySide6: copy missing libQt63DQuickScene3D.so along with its dependencies to avoid runtime issues RUN cp ${QT_DIR}/lib/libQt63DQuickScene3D.so.6.8.3 ${MESHROOM_BUNDLE}/lib/PySide6/Qt/qml/QtQuick/Dialogs && \ mv ${MESHROOM_BUNDLE}/lib/PySide6/Qt/qml/QtQuick/Dialogs/libQt63DQuickScene3D.so.6.8.3 ${MESHROOM_BUNDLE}/lib/PySide6/Qt/qml/QtQuick/Dialogs/libQt63DQuickScene3D.so.6 && \ cp ${MESHROOM_BUNDLE}/lib/PySide6/Qt/lib/libQt6Concurrent.so.6 ${MESHROOM_BUNDLE}/lib/PySide6/Qt/qml/QtQuick/Dialogs && \ cp ${MESHROOM_BUNDLE}/lib/PySide6/Qt/qml/Qt3D/Animation/libQt63DAnimation.so.6 ${MESHROOM_BUNDLE}/lib/PySide6/Qt/qml/QtQuick/Dialogs && \ cp ${MESHROOM_BUNDLE}/lib/PySide6/Qt/qml/Qt3D/Core/libQt63DCore.so.6 ${MESHROOM_BUNDLE}/lib/PySide6/Qt/qml/QtQuick/Dialogs && \ cp ${MESHROOM_BUNDLE}/lib/PySide6/Qt/qml/Qt3D/Input/libQt63DInput.so.6 ${MESHROOM_BUNDLE}/lib/PySide6/Qt/qml/QtQuick/Dialogs && \ cp ${MESHROOM_BUNDLE}/lib/PySide6/Qt/qml/Qt3D/Logic/libQt63DLogic.so.6 ${MESHROOM_BUNDLE}/lib/PySide6/Qt/qml/QtQuick/Dialogs && \ cp ${MESHROOM_BUNDLE}/lib/PySide6/Qt/qml/Qt3D/Render/libQt63DRender.so.6 ${MESHROOM_BUNDLE}/lib/PySide6/Qt/qml/QtQuick/Dialogs # Enable SSH X11 forwarding, needed when the Docker image # is run on a remote machine RUN apt install ssh xauth && \ systemctl enable ssh && \ mkdir -p /run/sshd RUN sed -i "s/^.*X11Forwarding.*$/X11Forwarding yes/; s/^.*X11UseLocalhost.*$/X11UseLocalhost no/; s/^.*PermitRootLogin prohibit-password/PermitRootLogin yes/; s/^.*X11UseLocalhost.*/X11UseLocalhost no/;" /etc/ssh/sshd_config RUN echo "root:meshroom" | chpasswd WORKDIR /root EXPOSE 22 CMD ["/usr/sbin/sshd", "-D"] ================================================ FILE: docker/Dockerfile_ubuntu_deps ================================================ ARG AV_VERSION ARG CUDA_VERSION ARG UBUNTU_VERSION FROM alicevision/alicevision:${AV_VERSION}-ubuntu${UBUNTU_VERSION}-cuda${CUDA_VERSION} LABEL maintainer="AliceVision Team alicevision-team@googlegroups.com" # Execute with nvidia docker (https://github.com/nvidia/nvidia-docker/wiki/Installation-(version-2.0)) # docker run -it --runtime=nvidia meshroom ENV MESHROOM_DEV=/opt/Meshroom \ MESHROOM_BUILD=/tmp/Meshroom_build \ QT_DIR=/opt/Qt/6.8.3/gcc_64 \ QT_CI_LOGIN=alicevisionjunk@gmail.com \ QT_CI_P=azerty1. # Install libs needed by Qt RUN apt-get update && \ DEBIAN_FRONTEND=noninteractive apt-get install -yq --no-install-recommends \ flex \ fontconfig \ libfreetype6 \ libglib2.0-0 \ libice6 \ libx11-6 \ libxcb1 \ libxext6 \ libxi6 \ libxrender1 \ libsm6 RUN apt-get update && \ DEBIAN_FRONTEND=noninteractive apt-get install -yq --no-install-recommends \ libxt-dev \ libosmesa-dev \ libgl-dev \ libegl-dev \ libglu-dev \ libxkbcommon-x11-0 \ libz-dev \ systemd \ ssh RUN apt-get update && \ DEBIAN_FRONTEND=noninteractive apt-get install -yq --no-install-recommends \ libxcb1-dev \ libxcb-icccm4 \ libxcb-render-util0 \ libxcb-shape0 \ libxcb-keysyms1 \ libxcb-image0 \ libxkbcommon-dev RUN apt-get install -y --no-install-recommends \ software-properties-common # Install Python3 # RUN apt install python3-pip -y && pip3 install --upgrade pip # Install Qt (to build plugins) WORKDIR /tmp/qt COPY dl/qt.run /tmp/qt RUN chmod +x qt.run RUN ./qt.run --root /opt/Qt --verbose --email ${QT_CI_LOGIN} --password ${QT_CI_P} --accept-obligations \ --accept-licenses --default-answer --platform minimal --auto-answer installationErrorWithCancel=Ignore \ --no-force-installations --no-default-installations --confirm-command \ install qt.qt6.683.linux_gcc_64 qt.qt6.683.addons.qtcharts qt.qt6.683.addons.qt3d RUN rm qt.run # Strip sections containing ".note.ABI.tag" from .so: https://github.com/Microsoft/WSL/issues/3023 RUN find ${QT_DIR}/lib/ -name '*.so' | xargs strip --remove-section=.note.ABI-tag COPY ./*requirements.txt ./setup.py ${MESHROOM_DEV}/ # Install Meshroom requirements and freeze bundle WORKDIR "${MESHROOM_DEV}" RUN python -m pip install -r dev_requirements.txt -r requirements.txt ================================================ FILE: docker/build-all.sh ================================================ #!/bin/sh set -e test -d docker || ( echo This script must be run from the top level Meshroom directory exit 1 ) CUDA_VERSION=12.1.1 UBUNTU_VERSION=22.04 docker/build-ubuntu.sh CUDA_VERSION=12.1.1 ROCKY_VERSION=9 docker/build-rocky.sh ================================================ FILE: docker/build-rocky.sh ================================================ #!/bin/bash set -e test -z "$MESHROOM_VERSION" && MESHROOM_VERSION="$(git rev-parse --abbrev-ref HEAD)-$(git rev-parse --short HEAD)" test -z "$AV_VERSION" && echo "AliceVision version not specified, set AV_VERSION in the environment" && exit 1 test -z "$CUDA_VERSION" && CUDA_VERSION=12.1.1 test -z "$ROCKY_VERSION" && ROCKY_VERSION=9 test -d docker || ( echo This script must be run from the top level Meshroom directory exit 1 ) test -d dl || \ mkdir dl test -f dl/qt.run || \ wget --no-check-certificate "https://download.qt.io/official_releases/online_installers/qt-online-installer-linux-x64-online.run" -O "dl/qt.run" # DEPENDENCIES docker build \ --rm \ --progress=plain \ --build-arg "CUDA_VERSION=${CUDA_VERSION}" \ --build-arg "ROCKY_VERSION=${ROCKY_VERSION}" \ --build-arg "AV_VERSION=${AV_VERSION}" \ --tag "alicevision/meshroom-deps:${MESHROOM_VERSION}-av${AV_VERSION}-rocky${ROCKY_VERSION}-cuda${CUDA_VERSION}" \ -f docker/Dockerfile_rocky_deps . # Meshroom docker build \ --rm \ --progress=plain \ --build-arg "MESHROOM_VERSION=${MESHROOM_VERSION}" \ --build-arg "CUDA_VERSION=${CUDA_VERSION}" \ --build-arg "ROCKY_VERSION=${ROCKY_VERSION}" \ --build-arg "AV_VERSION=${AV_VERSION}" \ --tag "alicevision/meshroom:${MESHROOM_VERSION}-av${AV_VERSION}-rocky${ROCKY_VERSION}-cuda${CUDA_VERSION}" \ -f docker/Dockerfile_rocky . ================================================ FILE: docker/build-ubuntu.sh ================================================ #!/bin/bash set -e test -z "$MESHROOM_VERSION" && MESHROOM_VERSION="$(git rev-parse --abbrev-ref HEAD)-$(git rev-parse --short HEAD)" test -z "$AV_VERSION" && echo "AliceVision version not specified, set AV_VERSION in the environment" && exit 1 test -z "$CUDA_VERSION" && CUDA_VERSION=12.1.1 test -z "$UBUNTU_VERSION" && UBUNTU_VERSION=22.04 test -d docker || ( echo This script must be run from the top level Meshroom directory exit 1 ) test -d dl || \ mkdir dl test -f dl/qt.run || \ wget --no-check-certificate "https://download.qt.io/official_releases/online_installers/qt-online-installer-linux-x64-online.run" -O "dl/qt.run" # DEPENDENCIES docker build \ --rm \ --progress=plain \ --build-arg "CUDA_VERSION=${CUDA_VERSION}" \ --build-arg "UBUNTU_VERSION=${UBUNTU_VERSION}" \ --build-arg "AV_VERSION=${AV_VERSION}" \ --tag "alicevision/meshroom-deps:${MESHROOM_VERSION}-av${AV_VERSION}-ubuntu${UBUNTU_VERSION}-cuda${CUDA_VERSION}" \ -f docker/Dockerfile_ubuntu_deps . # Meshroom docker build \ --rm \ --progress=plain \ --build-arg "MESHROOM_VERSION=${MESHROOM_VERSION}" \ --build-arg "CUDA_VERSION=${CUDA_VERSION}" \ --build-arg "UBUNTU_VERSION=${UBUNTU_VERSION}" \ --build-arg "AV_VERSION=${AV_VERSION}" \ --tag "alicevision/meshroom:${MESHROOM_VERSION}-av${AV_VERSION}-ubuntu${UBUNTU_VERSION}-cuda${CUDA_VERSION}" \ -f docker/Dockerfile_ubuntu . ================================================ FILE: docker/extract-rocky.sh ================================================ #!/bin/bash set -ex test -z "$MESHROOM_VERSION" && MESHROOM_VERSION="$(git rev-parse --abbrev-ref HEAD)-$(git rev-parse --short HEAD)" test -z "$AV_VERSION" && echo "AliceVision version not specified, set AV_VERSION in the environment" && exit 1 test -z "$CUDA_VERSION" && CUDA_VERSION="12.1.1" test -z "$ROCKY_VERSION" && ROCKY_VERSION="9" test -d docker || ( echo This script must be run from the top level Meshroom directory exit 1 ) VERSION_NAME=${MESHROOM_VERSION}-av${AV_VERSION}-rocky${ROCKY_VERSION}-cuda${CUDA_VERSION} # Retrieve the Meshroom bundle folder rm -rf ./Meshroom-${VERSION_NAME} CID=$(docker create alicevision/meshroom:${VERSION_NAME}) docker cp ${CID}:/opt/Meshroom_bundle ./Meshroom-${VERSION_NAME} docker rm ${CID} ================================================ FILE: docker/extract-ubuntu.sh ================================================ #!/bin/bash set -ex test -z "$MESHROOM_VERSION" && MESHROOM_VERSION="$(git rev-parse --abbrev-ref HEAD)-$(git rev-parse --short HEAD)" test -z "$AV_VERSION" && echo "AliceVision version not specified, set AV_VERSION in the environment" && exit 1 test -z "$CUDA_VERSION" && CUDA_VERSION="12.1.1" test -z "$UBUNTU_VERSION" && UBUNTU_VERSION="22.04" test -d docker || ( echo This script must be run from the top level Meshroom directory exit 1 ) VERSION_NAME=${MESHROOM_VERSION}-av${AV_VERSION}-ubuntu${UBUNTU_VERSION}-cuda${CUDA_VERSION} # Retrieve the Meshroom bundle folder rm -rf ./Meshroom-${VERSION_NAME} CID=$(docker create alicevision/meshroom:${VERSION_NAME}) docker cp ${CID}:/opt/Meshroom_bundle ./Meshroom-${VERSION_NAME} docker rm ${CID} ================================================ FILE: docs/.gitignore ================================================ # Sphinx build/ source/generated/ ================================================ FILE: docs/Makefile ================================================ # Minimal makefile for Sphinx documentation # # You can set these variables from the command line, and also # from the environment for the first two. SPHINXOPTS ?= SPHINXBUILD ?= sphinx-build SOURCEDIR = source BUILDDIR = build # Put it first so that "make" without argument is like "make help". help: @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) .PHONY: help Makefile # Catch-all target: route all unknown targets to Sphinx using the new # "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). %: Makefile @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) ================================================ FILE: docs/README.md ================================================ # Documentation We use [Sphinx](https://www.sphinx-doc.org) to generate Meshroom's documentation. ## Requirements To install all the requirements for building the documentation, simply run: ```bash pip install sphinx sphinx-rtd-theme myst-parser ``` You also need to have [Graphviz](https://graphviz.org/) installed. > Note: since Sphinx will import the entire `meshroom` package, all requirements for Meshroom must also be installed ## Build To generate the documentation, go to the `docs` folder and run the Sphinx makefile: ```bash cd meshroom/docs make html ``` To access the documentation, simply go to `meshroom/docs/build/html` and open `index.html` in a browser. ================================================ FILE: docs/make.bat ================================================ @ECHO OFF pushd %~dp0 REM Command file for Sphinx documentation if "%SPHINXBUILD%" == "" ( set SPHINXBUILD=sphinx-build ) set SOURCEDIR=source set BUILDDIR=build %SPHINXBUILD% >NUL 2>NUL if errorlevel 9009 ( echo. echo.The 'sphinx-build' command was not found. Make sure you have Sphinx echo.installed, then set the SPHINXBUILD environment variable to point echo.to the full path of the 'sphinx-build' executable. Alternatively you echo.may add the Sphinx directory to PATH. echo. echo.If you don't have Sphinx installed, grab it from echo.https://www.sphinx-doc.org/ exit /b 1 ) if "%1" == "" goto help %SPHINXBUILD% -M %1 %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% goto end :help %SPHINXBUILD% -M help %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% :end popd ================================================ FILE: docs/requirements.txt ================================================ myst-parser ================================================ FILE: docs/source/_ext/__init__.py ================================================ ================================================ FILE: docs/source/_ext/fetch_md.py ================================================ # Sphinx extension defining the fetch_md directive # # Goal: # include the content of a given markdown file # # Usage: # .. fetch_md:: path/to/file.md # the filepath is relative to the project base directory # # Note: # some markdown files contain links to other files that belong to the project # since those links are relative to the file location, they are no longer valid in the new context # therefore we try to update these links but it is not always possible import os from docutils.nodes import SparseNodeVisitor from docutils.parsers.rst import Directive from utils import md_to_docutils, get_link_key class Relinker(SparseNodeVisitor): def relink(self, node, base_dir): key = get_link_key(node) if key is None: return link = node.attributes[key] if link.startswith('http') or link.startswith('mailto'): return if link.startswith('/'): link = link[1:] node.attributes[key] = base_dir+'/'+link def visit_image(self, node): self.relink(node, os.getenv('PROJECT_DIR')) class FetchMd(Directive): required_arguments = 1 def run(self): path = os.path.abspath(os.getenv('PROJECT_DIR')+'/'+self.arguments[0]) result = [] try: with open(path) as file: text = file.read() doc = md_to_docutils(text) relinker = Relinker(doc) doc.walk(relinker) result.append(doc[0]) except FileNotFoundError: pass return result def setup(app): app.add_directive('fetch_md', FetchMd) return { 'version': '0.1', 'parallel_read_safe': True, 'parallel_write_safe': True } ================================================ FILE: docs/source/_ext/meshroom_doc.py ================================================ # Sphinx extension defining the meshroom_doc directive # # Goal: # create specific documentation content for meshroom objects # # Usage: # .. meshroom_doc:: # :module: module_name # :class: class_name # # Note: # for now this tool focuses only on meshroom nodes from docutils.parsers.rst import Directive from utils import md_to_docutils import importlib from meshroom.core import desc class MeshroomDoc(Directive): required_arguments = 4 def parse_args(self): module_name = self.arguments[self.arguments.index(':module:')+1] class_name = self.arguments[self.arguments.index(':class:')+1] return (module_name, class_name) def run(self): result = [] # Import module and class module_name, class_name = self.parse_args() module = importlib.import_module(module_name) node_class = getattr(module, class_name) # Class inherits desc.Node if issubclass(node_class, desc.Node): node = node_class() # Category doc = md_to_docutils('**Category**: {}'.format(node.category)) result.extend(doc.children) # Documentation doc = md_to_docutils(node.documentation) result.extend(doc.children) # Inputs text_inputs = '**Inputs**: \n' for attr in node.inputs: text_inputs += '- {} ({})\n'.format(attr._name, attr.__class__.__name__) doc = md_to_docutils(text_inputs) result.extend(doc.children) # Outputs text_outputs = '**Outputs**: \n' for attr in node.outputs: text_outputs += '- {} ({})\n'.format(attr._name, attr.__class__.__name__) doc = md_to_docutils(text_outputs) result.extend(doc.children) return result def setup(app): app.add_directive("meshroom_doc", MeshroomDoc) return { 'version': '0.1', 'parallel_read_safe': True, 'parallel_write_safe': True, } ================================================ FILE: docs/source/_ext/utils.py ================================================ # Utility functions for custom Sphinx extensions from myst_parser.docutils_ import Parser from myst_parser.mdit_to_docutils.base import make_document # Given a string written in markdown # parse its content # and return the corresponding docutils document def md_to_docutils(text): parser = Parser() doc = make_document(parser_cls=Parser) parser.parse(text, doc) return doc # Given a docutils node # find an attribute that corresponds to a link (if it exists) # and return its key def get_link_key(node): link_keys = ['uri', 'refuri', 'refname'] for key in link_keys: if key in node.attributes.keys(): return key return None ================================================ FILE: docs/source/_templates/autosummary/class.rst ================================================ {{ fullname | escape | underline}} .. inheritance-diagram:: {{ fullname }} .. meshroom_doc:: :module: {{ module }} :class: {{ objname }} .. currentmodule:: {{ module }} .. autoclass:: {{ objname }} {% block methods %} .. automethod:: __init__ {% if methods %} .. rubric:: {{ _('Methods') }} .. autosummary:: {% for item in methods %} ~{{ name }}.{{ item }} {%- endfor %} {% endif %} {% endblock %} {% block attributes %} {% if attributes %} .. rubric:: {{ _('Attributes') }} .. autosummary:: {% for item in attributes %} ~{{ name }}.{{ item }} {%- endfor %} {% endif %} {% endblock %} ================================================ FILE: docs/source/_templates/autosummary/module.rst ================================================ {{ fullname | escape | underline}} .. automodule:: {{ fullname }} {% block attributes %} {% if attributes %} .. rubric:: {{ _('Module Attributes') }} .. autosummary:: {% for item in attributes %} {{ item }} {%- endfor %} {% endif %} {% endblock %} {% block functions %} {% if functions %} .. rubric:: {{ _('Functions') }} .. autosummary:: {% for item in functions %} {{ item }} {%- endfor %} {% endif %} {% endblock %} {% block classes %} {% if classes %} .. rubric:: {{ _('Classes') }} .. autosummary:: :toctree: :recursive: {% for item in classes %} {{ item }} {%- endfor %} {% endif %} {% endblock %} {% block exceptions %} {% if exceptions %} .. rubric:: {{ _('Exceptions') }} .. autosummary:: {% for item in exceptions %} {{ item }} {%- endfor %} {% endif %} {% endblock %} {% block modules %} {% if modules %} .. rubric:: Modules .. autosummary:: :toctree: :recursive: {% for item in modules %} {{ item }} {%- endfor %} {% endif %} {% endblock %} ================================================ FILE: docs/source/api.rst ================================================ Python API Reference ==================== .. autosummary:: :recursive: :toctree: generated meshroom tests ================================================ FILE: docs/source/changes.rst ================================================ Release Notes ============= .. fetch_md:: CHANGES.md ================================================ FILE: docs/source/conf.py ================================================ # Configuration file for the Sphinx documentation builder. # # For the full list of built-in configuration values, see the documentation: # https://www.sphinx-doc.org/en/master/usage/configuration.html # -- Project information ----------------------------------------------------- # https://www.sphinx-doc.org/en/master/usage/configuration.html#project-information import os from pathlib import Path import sys os.environ['PROJECT_DIR'] = Path('../..').resolve().as_posix() sys.path.append(os.path.abspath(os.getenv('PROJECT_DIR'))) sys.path.append(os.path.abspath('./_ext')) project = 'Meshroom' copyright = '2025, AliceVision Association' author = 'AliceVision Association' # -- General configuration --------------------------------------------------- # https://www.sphinx-doc.org/en/master/usage/configuration.html#general-configuration extensions = [ 'sphinx.ext.autodoc', 'sphinx.ext.autosummary', 'fetch_md', 'meshroom_doc', 'sphinx.ext.graphviz', 'sphinx.ext.inheritance_diagram' ] templates_path = ['_templates'] exclude_patterns = [] # -- Options for HTML output ------------------------------------------------- # https://www.sphinx-doc.org/en/master/usage/configuration.html#options-for-html-output html_theme = 'sphinx_rtd_theme' html_static_path = ['_static'] ================================================ FILE: docs/source/index.rst ================================================ Welcome to meshroom's documentation! ==================================== .. toctree:: :maxdepth: 2 :caption: Contents: api install changes .. fetch_md:: README.md ================================================ FILE: docs/source/install.rst ================================================ Install ======= .. fetch_md:: INSTALL.md ================================================ FILE: localfarm/__init__.py ================================================ ================================================ FILE: localfarm/localFarm.py ================================================ #!/usr/bin/env python """ Local Farm : A simple local job runner """ from __future__ import annotations # For forward references in type hints import logging import json import socket import uuid from collections import defaultdict from pathlib import Path from typing import Dict, List, Generator logging.basicConfig( level=logging.INFO, format='%(asctime)s [%(name)s][%(levelname)s] %(message)s' ) logger = logging.getLogger("LocalFarm") logger.setLevel(logging.INFO) class LocalFarmEngine: """ Client to communicate with the farm backend. """ def __init__(self, root): self.root = Path(root) self.tcpPortFile = self.root / "backend.port" def connect(self): """ Connect to the backend. """ print("Connect to farm located at", self.root) if self.tcpPortFile.exists(): try: port = int(self.tcpPortFile.read_text()) sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) sock.connect(("localhost", port)) return sock except Exception as e: logger.error(f"Could not connect via TCP: {e}") raise ConnectionError("Cannot connect to farm backend") raise ConnectionError("Farm backend not found") def _call(self, method, **params): """ Make an query to the backend. """ request = { "method": method, "params": params } sock = self.connect() try: # Send request request_data = json.dumps(request) + "\n" sock.sendall(request_data.encode("utf-8")) # Receive response response_data = b"" while True: chunk = sock.recv(4096) if not chunk: break response_data += chunk if b"\n" in chunk: break response = json.loads(response_data.decode("utf-8")) if not response.get("success"): raise RuntimeError(response.get("error", "Unknown error")) return response finally: sock.close() def submit_job(self, job: Job): """ Submit the job to the farm. """ # Create the job createdJob = self._call("create_job", name=job.name) jid = createdJob["jid"] # Create the tasks tasksCreated = {} for task in job.tasksDFS(): parentTasks = job.getTaskDependencies(task) deps = [] for parentTask in parentTasks: if parentTask not in tasksCreated: raise RuntimeError(f"Parent task {parentTask.name} not created yet") deps.append(tasksCreated[parentTask]) createdTask = self._call("create_task", jid=jid, name=task.name, command=task.command, metadata=task.metadata, dependencies=deps, env=task.env) tasksCreated[task] = createdTask["tid"] # Submit the job self._call("submit_job", jid=jid) return {"jid": jid} def create_additional_task(self, jid, tid, task): """ Create new task in an existing job. """ createdTask = self._call("expand_task", jid=jid, name=task.name, command=task.command, metadata=task.metadata, parentTid=tid, env=task.env) return {"tid": createdTask["tid"]} def get_job_info(self, jid): """ Get job status. """ return self._call("get_job_info", jid=jid)["result"] def pause_job(self, jid): """ Pause a job. """ return self._call("pause_job", jid=jid) def unpause_job(self, jid): """ Resume a job. """ return self._call("unpause_job", jid=jid) def interrupt_job(self, jid): """ Interrupt a job. """ return self._call("interrupt_job", jid=jid) def restart_job(self, jid): """ Restart a job. """ return self._call("restart_job", jid=jid) def restart_error_tasks(self, jid): """ Restart error tasks. """ return self._call("restart_error_tasks", jid=jid) def stop_task(self, jid, tid): """ Stop a specific task. """ return self._call("stop_task", jid=jid, tid=tid) def skip_task(self, jid, tid): """ Stop a specific task. """ return self._call("skip_task", jid=jid, tid=tid) def restart_task(self, jid, tid): """ Restart a task. """ return self._call("restart_task", jid=jid, tid=tid) def list_jobs(self) -> list: """ List all jobs. """ return self._call("list_jobs")["jobs"] def get_job_status(self, jid: int) -> dict: for job in self.list_jobs(): if job["jid"] == jid: return job return {} def get_job_errors(self, jid: int) -> str: """ Get job error logs. """ return self._call("get_job_errors", jid=jid)["result"] def ping(self): """ Check if backend is alive. """ try: self.connect().close() return True except Exception: return False class Task: def __init__(self, name, command, metadata=None, env=None): self.uid = str(uuid.uuid1()) self.name = name self.command = command self.metadata = metadata or {} self.env = env or {} def __repr__(self): return f"" def __hash__(self): return hash(self.uid) class Job: def __init__(self, name): self.name = name self.tasks: Dict[str, Task] = {} self.dependencies: Dict[str: List[str]] = defaultdict(set) self.reverseDependencies: Dict[str: List[str]] = defaultdict(set) self._engine: LocalFarmEngine = None def setEngine(self, engine: LocalFarmEngine): self._engine = engine def addTask(self, task): if task.name in self.tasks: raise ValueError(f"Task {task} already exists in job") self.tasks[task.uid] = task def addTaskDependency(self, task: Task, dependsOn: Task): if task.uid not in self.tasks: raise ValueError(f"Task {task} not found in job") if dependsOn.uid not in self.tasks: raise ValueError(f"Task {dependsOn} not found in job") self.dependencies[task.uid].add(dependsOn.uid) self.reverseDependencies[dependsOn.uid].add(task.uid) if self.hasCycle(): # Rollback self.dependencies[task.uid].remove(dependsOn.uid) self.reverseDependencies[dependsOn.uid].remove(task.uid) raise ValueError("Adding this task creates a cycle in the job dependencies") def getTaskDependencies(self, task): return [self.tasks[depUid] for depUid in self.dependencies.get(task.uid, [])] def getRootTasks(self) -> List[Task]: roots = [] for taskUid, task in self.tasks.items(): if not self.dependencies.get(taskUid): roots.append(task) return roots def hasCycle(self) -> bool: """ Check there are no cycles in the task graph. """ def exploreTask(taskUid, taskParents=None): taskParents = taskParents or set() if taskUid in taskParents: return True childrenParents = taskParents.copy() childrenParents.add(taskUid) for childUid in self.reverseDependencies[taskUid]: failed = exploreTask(childUid, childrenParents) if failed: return True return False # Start from root and explore down roots = self.getRootTasks() if not roots: return True for task in roots: failed = exploreTask(task.uid) if failed: return True return False def tasksDFS(self) -> Generator[Task]: """ Return tasks in topological order (dependencies before dependents). Tasks closer to roots appear first. """ taskLevels = {} def exploreTask(task: str, currentLevel=0): if task in taskLevels: if currentLevel > taskLevels[task]: taskLevels[task] = currentLevel else: taskLevels[task] = currentLevel for child in self.reverseDependencies[task]: exploreTask(child, currentLevel + 1) # Start from root and explore down for task in self.getRootTasks(): exploreTask(task.uid) taskByLevel = defaultdict(list) for taskUid, level in taskLevels.items(): taskByLevel[level].append(self.tasks[taskUid]) levels = sorted(list(taskByLevel.keys())) for level in levels: tasks = taskByLevel[level] for task in tasks: yield task def submit(self, engine: LocalFarmEngine = None): engine = engine or self._engine if engine: result = engine.submit_job(self) return result else: raise ValueError("No LocalFarmEngine set for this job") def test(): # _ B - D - F - G - H _ # / / \ \ # A - / - I -- J # \ / # - C - E - K - L - M # \_____/ job = Job("job") for node in ["F", "B", "K", "J", "A", "M", "L", "E", "C", "D", "G", "H", "I"]: job.addTask(Task(node, "")) def addTaskDependencies(taskName, parentTaskName): task = next(t for t in job.tasks.values() if t.name == taskName) parentTask = next(t for t in job.tasks.values() if t.name == parentTaskName) job.addTaskDependency(task, parentTask) addTaskDependencies("B", "A") addTaskDependencies("C", "A") addTaskDependencies("D", "B") addTaskDependencies("E", "C") addTaskDependencies("F", "D") addTaskDependencies("C", "L") addTaskDependencies("F", "E") addTaskDependencies("K", "E") addTaskDependencies("M", "K") addTaskDependencies("G", "F") addTaskDependencies("H", "G") addTaskDependencies("I", "G") addTaskDependencies("J", "I") addTaskDependencies("J", "H") print("Tasks order : ", end="") for task in job.tasksDFS(): print(f"{task.name} -> ", end="") print("END") ================================================ FILE: localfarm/localFarmBackend.py ================================================ #!/usr/bin/env python """ Local Farm : A simple local job runner """ import os import sys import random import argparse import json import shlex import time import signal import logging import subprocess from pathlib import Path from datetime import datetime from collections import defaultdict from typing import Union, Dict, List from enum import Enum # For the tcp server import threading from socketserver import BaseRequestHandler, ThreadingTCPServer FARM_MAX_PARALLEL_TASKS = 10 MAX_BYTES_REQUEST = 4096 # 8192 / 65536 if needed PathLike = Union[str, Path] logging.basicConfig( level=logging.INFO, format='%(asctime)s [%(name)s][%(levelname)s] %(message)s' ) logger = logging.getLogger("LocalFarmBackend") logger.setLevel(logging.DEBUG) class Status(Enum): NONE = 0 SUBMITTED = 1 RUNNING = 2 ERROR = 3 STOPPED = 4 KILLED = 5 SUCCESS = 6 PAUSED = 7 class Task: def __init__(self, jid: str, tid: str, label: str, command: str, metadata: dict, jobDir: PathLike, env: dict = None): self.jid: str = jid self.tid: str = tid self.parentTids = [] # Tasks that must be completed before this one self.childTids = [] # Task that depend on this one self.label: str = label self.command: str = command self.metadata: dict = metadata or {} self.env: dict = env or {} self.taskDir: Path = Path(jobDir) / "tasks" self.taskDir.mkdir(parents=True, exist_ok=True) self.status: Status = Status.NONE self.created_at = datetime.now() self.started_at = None self.finished_at = None self.returnCode = None self.process = None self.logFile: Path = self.taskDir / f"{tid}.log" def to_dict(self): return { "jid": self.jid, "tid": self.tid, "label": self.label, "command": self.command, "metadata": self.metadata, "env": self.env, "status": self.status.name, "created_at": self.created_at.isoformat(), "started_at": self.started_at.isoformat() if self.started_at else None, "finished_at": self.finished_at.isoformat() if self.finished_at else None, "returnCode": self.returnCode } class Job: def __init__(self, jid: str, label: str, farmRoot: PathLike, maxParallel: int=4): self.jid: str = jid self.label: str = label self.submitted: bool = False self.jobDir: Path = Path(farmRoot) / "jobs" / str(jid) self.jobDir.mkdir(parents=True, exist_ok=True) self.lastJid = 0 self.status: Status = Status.NONE self.created_at = datetime.now() self.started_at = None self.tasks: List[Task] = [] self.maxParallel: int = maxParallel # Runtime tasks status self.__stoppedTasks = [] def to_dict(self): return { "jid": self.jid, "label": self.label, "submitted": self.submitted, "status": self.status.name, "created_at": self.created_at.isoformat(), "started_at": self.started_at.isoformat() if self.started_at else None, "tasks": [t.to_dict() for t in self.tasks], "maxParallel": self.maxParallel } @property def errorLogs(self): errorLog = "" for task in self.tasks: if task.status in (Status.ERROR, Status.STOPPED, Status.KILLED): errorLog += f"Task {task.tid} failed :\n{task.logFile.read_text()}\n" return errorLog @property def rootTasks(self): return [t for t in self.tasks if len(t.parentTids) == 0] def addTaskDependency(self, parentTask: Task, childTask: Task): parentTask.childTids.append(childTask.tid) childTask.parentTids.append(parentTask.tid) def canStartTask(self, task: Task): for parentTid in task.parentTids: parentTask = next((t for t in self.tasks if t.tid == parentTid), None) if parentTask and parentTask.status != Status.SUCCESS: return False return True def getNextTaskToProcess(self): # TODO : better to use the DFS implemented in localFarm.py # Function to explore tasks def exploreTask(task): if task.status == Status.SUBMITTED: return task if task.status != Status.SUCCESS: return None children = [t for t in self.tasks if t.tid in task.childTids] for taskCandidate in children: submittedTask = exploreTask(taskCandidate) if submittedTask: return submittedTask return None for task in self.rootTasks: submittedTask = exploreTask(task) if submittedTask: return submittedTask return None def start(self): self.status = Status.RUNNING self.started_at = datetime.now() for task in self.tasks: task.status = Status.SUBMITTED def updateStatusFromTasks(self): for task in self.tasks: if task.status in (Status.ERROR, Status.STOPPED, Status.KILLED): self.status = Status.STOPPED return elif task.status == Status.RUNNING: self.status = Status.RUNNING return def interrupt(self): logger.info(f"Interrupt job {self.jid}") self.status = Status.STOPPED for task in self.tasks: if task.status == Status.RUNNING and task.process: logger.info(f"Interrupt task {task.tid}") self.__stoppedTasks.append(task) task.process.terminate() task.status = Status.STOPPED logger.info(f"Job {self.jid} interrupted") def restart(self): self.interrupt() self.start() def restartErrorTasks(self): self.status = Status.RUNNING for task in self.tasks: if task.status in (Status.ERROR, Status.STOPPED, Status.KILLED): task.status = Status.SUBMITTED def resume(self): logger.info(f"Resume job {self.jid}") self.status = Status.RUNNING for task in self.__stoppedTasks: if task.status == Status.STOPPED: task.status = Status.SUBMITTED self.__stoppedTasks = [] def stopTask(self, tid): for task in self.tasks: if task.tid == tid: if task.process and task.process.poll() is None: task.process.terminate() task.status = Status.STOPPED logger.info(f"Task {tid} stopped") return True return False def skipTask(self, tid): task = next((t for t in self.tasks if t.tid == tid), None) if not task: return False task.status = Status.SUCCESS if task.process and task.process.poll() is None: task.process.terminate() logger.info(f"Task {tid} skipped") return True def restartTask(self, tid): for task in self.tasks: if task.tid == tid: if task.process and task.process.poll() is None: task.process.terminate() task.status = Status.SUBMITTED task.started_at = None task.finished_at = None task.return_code = None task.process = None logger.info(f"Task {tid} rescheduled") return True return False class LocalFarmEngine: def __init__(self, root: PathLike, maxParallel: int = FARM_MAX_PARALLEL_TASKS): self.root: Path = Path(root) self.root.mkdir(parents=True, exist_ok=True) # Jobs self.jobs: Dict[int, Job] = {} self.lastJid = 0 self.running = False self.lock = threading.RLock() # PID file self.pidFile = self.root / "farm.pid" self.pidFile.write_text(str(os.getpid())) # Socket path self.tcpPortFile = self.root / "backend.port" logger.info(f"Backend initialized at {self.root}") self.maxParallel: int = maxParallel def start(self): """ Start the server. """ logger.info(f"Starting the server...") # Start the server to listen to queries self.running = True handler = lambda *args: LocalFarmRequestHandler(self, *args) self.server = ThreadingTCPServer(('localhost', 0), handler) port = self.server.server_address[1] self.tcpPortFile.write_text(str(port)) logger.info(f"Server listening on TCP port: {port}") # Start server in separate thread serverThread = threading.Thread(target=self.server.serve_forever, daemon=True) serverThread.start() # Start task processor processThread = threading.Thread(target=self.taskRunner, daemon=True) processThread.start() # Wait for shutdown signal signal.signal(signal.SIGTERM, self.signalHandler) signal.signal(signal.SIGINT, self.signalHandler) try: while self.running: time.sleep(1) finally: self.cleanup() def signalHandler(self, signum, frame): logger.info(f"Received signal {signum}, shutting down...") self.running = False def taskRunner(self): """Background thread that processes tasks""" while self.running: try: with self.lock: self.processJobs() time.sleep(0.5) except Exception as e: logger.error(f"Error in task processor: {e}", exc_info=True) def processJobs(self): """ Process all active jobs. """ runningTasks = defaultdict(list) tasksToStart = defaultdict(list) for job in self.jobs.values(): job.updateStatusFromTasks() if not job.submitted or job.status in [Status.PAUSED, Status.SUCCESS, Status.STOPPED]: continue elif job.status == Status.SUBMITTED: job.start() # Update running tasks runningTasks[job.jid] = [t for t in job.tasks if t.status == Status.RUNNING] # Update tasks to start for task in job.tasks: if task.status == Status.SUBMITTED: if job.canStartTask(task): tasksToStart[job].append(task) elif task.status == Status.RUNNING and task.process: # Check if process finished returncode = task.process.poll() if returncode is not None: self.finishTask(task, returncode) # Check if job is complete if any(t.status in [Status.ERROR, Status.STOPPED, Status.KILLED] for t in job.tasks): job.status = Status.ERROR logger.error(f"Job {job.jid} failed !") elif all(t.status in [Status.SUCCESS, Status.NONE] for t in job.tasks): job.status = Status.SUCCESS logger.info(f"Job {job.jid} finished !") # else : keep running or paused # Launch tasks nbRunningTasks = sum(len(tasks) for tasks in runningTasks.values()) tasks = [] for job, jobTasks in tasksToStart.items(): # while True: # nextTask = job.getNextTaskToProcess() # if not nextTask: # break for task in jobTasks: tasks.append((job, task)) random.shuffle(tasks) # Randomize task order to be fair between jobs for job, task in tasks: nbJobRunningTasks = len(runningTasks[job.jid]) if job.maxParallel > nbJobRunningTasks and self.maxParallel > nbRunningTasks: nbRunningTasks += 1 nbJobRunningTasks += 1 self.startTask(task) def startTask(self, task: Task): """ Start a task process. """ logger.info(f"Starting task {task.tid}: {task.command}") task.status = Status.RUNNING task.started_at = datetime.now() # Create log file additional_env = { "LOCALFARM_CURRENT_JID": str(task.jid), "LOCALFARM_CURRENT_TID": str(task.tid), "MR_LOCAL_FARM_PATH": str(self.root) } additional_env.update(task.env) process_env = os.environ.copy() process_env.update(additional_env) try: with open(task.logFile, "w") as log: log.write(f"# ========== Starting task {task.tid} at {task.started_at.isoformat()}" f" (command=\"{task.command}\") ==========\n") log.write(f"# process_env:\n") log.write(f"# Additional env variables:\n") for _k, _v in additional_env.items(): log.write(f"# - {str(_k)}={str(_v)}\n") log.write(f"\n") task.process = subprocess.Popen( task.command, # shlex.split(task.command), stdout=log, stderr=log, cwd=task.taskDir, env=process_env, shell=True ) except Exception as e: logger.error(f"Failed to start task {task.tid}: {e}") task.status = "error" task.finished_at = datetime.now() def finishTask(self, task: Task, returncode: int): task.finished_at = datetime.now() task.return_code = returncode if returncode == 0: task.status = Status.SUCCESS logger.info(f"Task {task.tid} completed") else: task.status = Status.ERROR logger.error(f"Task {task.tid} failed with code {returncode}") with open(task.logFile, "a") as log: log.write(f"\n# ========== Task {task.tid} finished at {task.finished_at.isoformat()} with status {task.status} ==========\n") def cleanup(self): logger.info("Cleaning up...") with self.lock: for job in self.jobs.values(): for task in job.tasks: if task.process and task.process.poll() is None: logger.info(f"Terminating task {task.tid}") task.process.terminate() try: task.process.wait(timeout=5) except subprocess.TimeoutExpired: task.process.kill() self.server.shutdown() self.pidFile.unlink(missing_ok=True) logger.info("Cleanup complete") # ====================== # API Calls # ====================== # Author def create_job(self, name): """ Create a new job. """ with self.lock: # Generate new jid self.lastJid += 1 jid = self.lastJid try: job = Job(jid, label=name, farmRoot=self.root) except Exception as err: return {"success": False, "error": str(err)} self.jobs[jid] = job logger.info(f"Created job {jid}") return {"success": True, "jid": jid} def create_task(self, jid, name, command, metadata, dependencies, env=None): """ Add a task to a job. """ with self.lock: if jid not in self.jobs: return {"success": False, "error": "Job not found"} job = self.jobs[jid] job.lastJid += 1 tid = job.lastJid task = Task(jid, tid, name, command, metadata, job.jobDir, env=env) job.tasks.append(task) for parentTid in dependencies: parentTask = next((t for t in job.tasks if t.tid == parentTid), None) if parentTask: job.addTaskDependency(parentTask, task) else: logger.warning(f"Task {tid} : Cannot add dependency to {parentTid}, task not found in job {jid}") logger.info(f"Added task {tid} to job {jid}") return {"success": True, "tid": tid} def expand_task(self, jid, name, command, metadata, parentTid, env=None): with self.lock: if jid not in self.jobs: logger.info(f"Available jobs: {list(self.jobs.keys())}") return {"success": False, "error": "Job not found"} job = self.jobs[jid] job.lastJid += 1 tid = job.lastJid task = Task(jid, tid, name, command, metadata, job.jobDir, env=env) task.status = Status.SUBMITTED job.tasks.append(task) parentTask = next((t for t in job.tasks if t.tid == parentTid), None) if not parentTask: logger.error(f"Could not expand task {parentTid} : cannot find it in the job {job} ({jid})") return {"success": False, "error": f"Parent task {parentTid} not found in job {jid}"} for childTid in parentTask.childTids: childTask = next((t for t in job.tasks if t.tid == childTid), None) if not childTask: logger.error(f"Could not find expanded task child {childTid}") job.addTaskDependency(task, childTask) logger.info(f"Added expanded task {tid} to job {jid}") return {"success": True, "tid": tid} def submit_job(self, jid): """ Create a new job. """ with self.lock: if jid not in self.jobs: return {'success': False, "error": "Job not found"} try: job = self.jobs[jid] job.submitted = True job.status = Status.SUBMITTED except Exception as err: return {"success": False, "error": str(err)} logger.info(f"Submitted job {jid}") return {"success": True, "jid": jid} # Query def get_job_info(self, jid): """ Get job status. """ with self.lock: if jid not in self.jobs: return {'success': False, "error": "Job not found"} job = self.jobs[jid] return {"success": True, "result": job.to_dict()} def get_job_errors(self, jid): """ Get job error logs. """ with self.lock: if jid not in self.jobs: return {'success': False, "error": "Job not found"} job = self.jobs[jid] return {"success": True, "result": job.errorLogs} def pause_job(self, jid): """ Pause a job. """ with self.lock: if jid not in self.jobs: return {"success": False, "error": "Job not found"} self.jobs[jid].status = Status.PAUSED logger.info(f"Job {jid} paused") return {"success": True} def unpause_job(self, jid): """ Resume a job. """ with self.lock: if jid not in self.jobs: return {"success": False, "error": "Job not found"} self.jobs[jid].resume() return {"success": True} def interrupt_job(self, jid): """ Interrupt a job and kill running tasks. """ with self.lock: if jid not in self.jobs: return {"success": False, "error": "Job not found"} self.jobs[jid].interrupt() return {"success": True} def restart_job(self, jid): """ Restarts a job and kill running tasks. """ with self.lock: if jid not in self.jobs: return {"success": False, "error": "Job not found"} self.jobs[jid].restart() return {"success": True} def restart_error_tasks(self, jid): """ Restarts all error tasks in the job. """ with self.lock: if jid not in self.jobs: return {"success": False, "error": "Job not found"} self.jobs[jid].restartErrorTasks() return {"success": True} def stop_task(self, jid, tid): """ Stop a specific task. """ with self.lock: if jid not in self.jobs: return {"success": False, "error": "Job not found"} res = self.jobs[jid].stopTask(tid) if res: return {"success": True} else: return {"success": False, "error": "Task not found"} def skip_task(self, jid, tid): """ Stop a specific task. """ with self.lock: if jid not in self.jobs: return {"success": False, "error": "Job not found"} res = self.jobs[jid].skipTask(tid) if res: return {"success": True} else: return {"success": False, "error": "Task not found"} def restart_task(self, jid, tid): """ Restart a task. """ with self.lock: if jid not in self.jobs: return {"success": False, "error": "Job not found"} res = self.jobs[jid].restartTask(tid) if res: return {"success": True} else: return {"success": False, "error": "Task not found"} def list_jobs(self): """ List all jobs. """ with self.lock: return { "success": True, "jobs": [job.to_dict() for job in self.jobs.values()] } class LocalFarmRequestHandler(BaseRequestHandler): """ Handle requests. """ def __init__(self, backend, *args, **kwargs): self.backend = backend super().__init__(*args, **kwargs) @property def pid(self): return self.server.server_address[1] def handle(self): """ Handle incoming request. """ try: # Read request data = b"" while True: token = self.request.recv(MAX_BYTES_REQUEST) if not token: break data += token if b"\n" in token: break if not data: return request = json.loads(data.decode("utf-8")) logger.debug(f"Received request: {request}") # Dispatch method method = request.get("method") params = request.get("params", {}) if not hasattr(self.backend, method): response = {"success": False, "error": f"Unknown request: {method}"} else: try: result = getattr(self.backend, method)(**params) response = result except Exception as e: logger.error(f"Error executing {method}: {e}", exc_info=True) response = {'success': False, 'error': str(e)} # Send response response_data = json.dumps(response) + '\n' self.request.sendall(response_data.encode('utf-8')) except Exception as e: logger.error(f"Error handling request: {e}", exc_info=True) def main(root): # Daemonize if os.fork() > 0: sys.exit(0) os.setsid() if os.fork() > 0: sys.exit(0) # Redirect standard file descriptors sys.stdout.flush() sys.stderr.flush() with open(os.devnull, 'r') as devnull: os.dup2(devnull.fileno(), sys.stdin.fileno()) backend = LocalFarmEngine(root=root) backend.start() if __name__ == "__main__": parser = argparse.ArgumentParser(description='Execute a Graph of processes.') parser.add_argument('--root', type=str, required=False, help='Root path for the farm.') args = parser.parse_args() root = args.root if not root: root = os.getenv("MR_LOCAL_FARM_PATH", os.path.join(os.path.expanduser("~"), ".local_farm")) main(root) ================================================ FILE: localfarm/localFarmLauncher.py ================================================ #!/usr/bin/env python import os import shutil import sys import time import signal import argparse from pathlib import Path import subprocess from collections import defaultdict from localfarm.localFarm import LocalFarmEngine class FarmLauncher: def __init__(self, root=None): self.root = Path(root or Path.home() / ".local_farm") self.root.mkdir(parents=True, exist_ok=True) self.pidFile = self.root / "farm.pid" self.logFile = self.root / "backend.log" def clean(self): """ Clean farm backend files. """ print("Clean farm files...") if self.logFile.exists(): self.logFile.unlink() if (self.root / "jobs").exists(): shutil.rmtree(str((self.root / "jobs"))) if not self.is_running(): self.pidFile.unlink(missing_ok=True) (self.root / "backend.port").unlink(missing_ok=True) print("Done.") def start(self): """ Start the farm backend. """ if self.is_running(): print("Farm backend is already running") return self.clean() print("Starting farm backend...") print(f"Farm root is: {self.root}") # Get path to backend script backendScript = Path(__file__).parent / "localFarmBackend.py" # Start backend as daemon with open(self.logFile, 'a') as log: subprocess.Popen( [sys.executable, str(backendScript), "--root", str(self.root)], stdout=log, stderr=log, # stderr=subprocess.PIPE, start_new_session=True ) # Wait for it to start for _ in range(10): time.sleep(0.5) if self.is_running(): print(f"Farm backend started (PID: {self.getFarmPid()})") print(f"Logs: {self.logFile}") return print("Failed to start farm backend") sys.exit(1) def stop(self): """ Stop the farm backend. """ if not self.is_running(): print("Farm backend is not running") return pid = self.getFarmPid() print(f"Stopping farm backend (PID: {pid})...") try: os.kill(pid, signal.SIGTERM) # Wait for it to stop for _ in range(10): time.sleep(0.5) if not self.is_running(): print("Farm backend stopped") return # Force kill if still running print("Force killing farm backend...") os.kill(pid, signal.SIGKILL) except ProcessLookupError: print("Backend process not found") self.pidFile.unlink(missing_ok=True) def restart(self): """Restart the farm backend""" self.stop() time.sleep(1) self.start() def getJobsInfo(self): if self.is_running(): # Try to get job list try: engine = LocalFarmEngine(root=self.root) jobs = engine.list_jobs() return jobs except Exception as e: raise ValueError(f"Could not fetch jobs: {e}") else: print("Farm backend is not running") return [] def status(self, allInfo=False): """ Show status of the farm backend. """ if self.is_running(): pid = self.getFarmPid() print(f"Farm backend is running (PID: {pid})") # Try to get job list try: engine = LocalFarmEngine(root=self.root) jobs = engine.list_jobs() print(f"Active jobs: {len(jobs)}") for job in jobs: jid = job.get("jid") taskByStatus = defaultdict(set) for task in job['tasks']: status = task.get("status", "UNKNOWN") taskByStatus[status].add(task.get("tid")) print(f" - {jid}: {job['status']} ({len(job['tasks'])} tasks) -> {dict(taskByStatus)}") if allInfo: for task in job['tasks']: print(f" * Task {task['tid']}: {task}") print("") except Exception as e: print(f"Could not get job list: {e}") else: print("Farm backend is not running") def is_running(self): """ Check if backend is running. """ pid = self.getFarmPid() if pid is None: return False try: os.kill(pid, 0) return True except ProcessLookupError: return False def getFarmPid(self): """ Get PID of running backend. """ if not self.pidFile.exists(): return None try: return int(self.pidFile.read_text()) except Exception: return None def main(root, command): launcher = FarmLauncher(root=root) if command == 'clean': return launcher.clean() if command == 'start': return launcher.start() elif command == 'stop': return launcher.stop() elif command == 'restart': return launcher.restart() elif command == 'status': return launcher.status() elif command == 'fullInfo': return launcher.status(allInfo=True) if __name__ == "__main__": parser = argparse.ArgumentParser(description='Local Farm Launcher') parser.add_argument('command', choices=['clean', 'start', 'stop', 'restart', 'status', 'fullInfo'], help='Command to execute') parser.add_argument('--root', required=False, help='Farm directory path') args = parser.parse_args() root = args.root if not root: root = os.getenv("MR_LOCAL_FARM_PATH", os.path.join(os.path.expanduser("~"), ".local_farm")) main(root, args.command) ================================================ FILE: localfarm/test.py ================================================ #!/usr/bin/env python import os from time import sleep from localfarm.localFarm import Task, Job, LocalFarmEngine from localfarm.localFarmLauncher import FarmLauncher from collections import defaultdict from typing import List class TestLocalFarm: def __init__(self, farmPath): self.launcher = FarmLauncher(root=farmPath) self.engine = LocalFarmEngine(farmPath) def prepare(self): self.launcher.clean() self.launcher.start() def createTask(self, job: Job, i: int, sleepTime=2, dependencies: List[Task] = None): dependencies = dependencies or [] task = Task(f"Task {i}", f"echo 'Hello from Task {i}' && sleep {sleepTime}") job.addTask(task) for parentTask in dependencies: job.addTaskDependency(task, parentTask) return task def expandTask(self, jid, tid, n=2): for i in range(n): task = Task(f"Expanded Task {i}", f"echo 'Hello from Expanded Task {i}' && sleep 5") self.engine.create_additional_task(jid, tid, task) def getTasksByStatus(self, jid: int): jobInfo = self.engine.get_job_status(jid) if not jobInfo: return {} taskByStatus = defaultdict(set) for task in jobInfo.get("tasks", []): status = task.get("status", "UNKNOWN") taskByStatus[status].add(task.get("tid")) return dict(taskByStatus) def run(self): # Create job job = Job("Example Job") job.setEngine(self.engine) # Add tasks task1 = self.createTask(job, 1, sleepTime=2, dependencies=[]) task2 = self.createTask(job, 2, sleepTime=2, dependencies=[task1]) task3 = self.createTask(job, 3, sleepTime=2, dependencies=[task1]) task4 = self.createTask(job, 4, sleepTime=2, dependencies=[task2, task3]) task5 = self.createTask(job, 5, sleepTime=2, dependencies=[task4]) # Submit job res = job.submit() jid = res['jid'] # Monitor job currentRunningTids = set() hasExpanded = False while True: sleep(1) tasks = self.getTasksByStatus(jid) if not tasks: print("No tasks found for job") break runningTids = tasks.get("RUNNING") activeTasks = tasks.get("SUBMITTED", set()).union(tasks.get("RUNNING", set())) if not activeTasks: print("All tasks completed") break if runningTids: runningTids = [int(t) for t in runningTids] newRunningTasks = set(runningTids) if currentRunningTids != newRunningTasks: print(f"Now running tasks: {runningTids} (active tasks: {activeTasks})") currentRunningTids = newRunningTasks expandingTid = 5 if not hasExpanded and expandingTid in runningTids: hasExpanded = True print(f"Expanding task {expandingTid}") self.expandTask(jid, expandingTid, n=2) def finish(self): self.launcher.stop() # self.launcher.clean() def test(): farm_path = os.getenv("MR_LOCAL_FARM_PATH", os.path.join(os.path.expanduser("~"), ".local_farm")) # farm_path = "/s/prods/mvg/_source_global/users/sonoleta/tmp/local_farm" _test = TestLocalFarm(farm_path) try: _test.prepare() _test.run() except Exception as e: print(f"Test failed: {e}") _test.finish() raise e finally: _test.finish() if __name__ == "__main__": test() ================================================ FILE: meshroom/__init__.py ================================================ from enum import Enum, IntEnum import logging import os import sys class VersionStatus(Enum): release = 1 develop = 2 __version__ = "2026.1.0" # Always increase the minor version when switching from release to develop. __version_status__ = VersionStatus.develop if __version_status__ is VersionStatus.develop: __version__ += "+" + __version_status__.name __version_label__ = __version__ # Modify version label if we are in a development phase. if __version_status__ is VersionStatus.develop: scriptPath = os.path.dirname(os.path.abspath(__file__)) headFilepath = os.path.join(scriptPath, "../.git/HEAD") if os.path.exists(headFilepath): # Add git branch name, if it is a git repository with open(headFilepath, "r") as headFile: data = headFile.readlines() branchName = data[0].split('/')[-1].strip() __version_label__ += " branch=" + branchName # Allow override from env variable if "REZ_MESHROOM_VERSION" in os.environ: __version_label__ += " package=" + os.environ.get("REZ_MESHROOM_VERSION") # Internal imports after the definition of the version from .common import init, Backend, strtobool # sys.frozen is initialized by cx_Freeze and identifies a release package isFrozen = getattr(sys, "frozen", False) useMultiChunks = bool(strtobool(os.environ.get("MESHROOM_USE_MULTI_CHUNKS", "True"))) # Logging def addTraceLevel(): """ From https://stackoverflow.com/a/35804945 """ levelName, methodName, levelNum = 'TRACE', 'trace', logging.DEBUG - 5 if hasattr(logging, levelName) or hasattr(logging, methodName)or hasattr(logging.getLoggerClass(), methodName): return def logForLevel(self, message, *args, **kwargs): if self.isEnabledFor(levelNum): self._log(levelNum, message, args, **kwargs) def logToRoot(message, *args, **kwargs): logging.log(levelNum, message, *args, **kwargs) logging.addLevelName(levelNum, levelName) setattr(logging, levelName, levelNum) setattr(logging.getLoggerClass(), methodName, logForLevel) setattr(logging, methodName, logToRoot) addTraceLevel() logStringToPython = { 'fatal': logging.CRITICAL, 'error': logging.ERROR, 'warning': logging.WARNING, 'info': logging.INFO, 'debug': logging.DEBUG, 'trace': logging.TRACE, } logging.getLogger().setLevel(logStringToPython[os.environ.get('MESHROOM_VERBOSE', 'warning')]) class MeshroomExitStatus(IntEnum): """ In case we want to catch some special case from the parent process We could use 3-125 for custom exist codes : https://tldp.org/LDP/abs/html/exitcodes.html """ SUCCESS = 0 ERROR = 1 # In some farm tools jobs are automatically re-tried, using ERROR_NO_RETRY will try to prevent that ERROR_NO_RETRY = -999 # It's actually -999 % 256 => 25 def setupEnvironment(backend=Backend.STANDALONE): """ Setup environment for Meshroom to work in a prebuilt, standalone configuration. Use 'MESHROOM_INSTALL_DIR' to simulate standalone configuration with a path to a Meshroom installation folder. # Meshroom standalone structure - Meshroom/ - aliceVision/ - bin/ # runtime bundled binaries (windows: exe + libs, unix: executables) - lib/ # runtime bundled libraries (unix: libs) - share/ # resource files - aliceVision/ - COPYING.md # AliceVision COPYING file - cameraSensors.db # sensor database - vlfeat_K80L3.tree # voctree file - lib/ # Python lib folder - qtPlugins/ - plugins/ Meshroom # main executable COPYING.md # Meshroom COPYING file """ init(backend) def addToEnvPath(var, val, index=-1): """ Add paths to the given environment variable. Args: var (str): the name of the variable to add paths to val (str or list of str): the path(s) to add index (int): insertion index """ if not val: return paths = os.environ.get(var, "").split(os.pathsep) if not isinstance(val, (list, tuple)): val = [val] if index == -1: paths.extend(val) elif index == 0: paths = val + paths else: raise ValueError("addToEnvPath: index must be -1 or 0.") os.environ[var] = os.pathsep.join(paths) # setup root directory (override possible by setting "MESHROOM_INSTALL_DIR" environment variable) rootDir = os.path.dirname(sys.executable) if isFrozen else os.environ.get("MESHROOM_INSTALL_DIR", None) logging.debug(f"isFrozen={isFrozen}") logging.debug(f"sys.executable={sys.executable}") logging.debug(f"rootDir={rootDir}") if rootDir: os.environ["MESHROOM_INSTALL_DIR"] = rootDir aliceVisionDir = os.path.join(rootDir, "aliceVision") # default directories aliceVisionBinDir = os.path.join(aliceVisionDir, "bin") aliceVisionShareDir = os.path.join(aliceVisionDir, "share", "aliceVision") qtPluginsDir = os.path.join(rootDir, "qtPlugins") pluginsDir = os.path.join(rootDir, "plugins") sensorDBPath = os.path.join(aliceVisionShareDir, "cameraSensors.db") voctreePath = os.path.join(aliceVisionShareDir, "vlfeat_K80L3.SIFT.tree") sphereDetectionModel = os.path.join(aliceVisionShareDir, "sphereDetection_Mask-RCNN.onnx") semanticSegmentationModel = os.path.join(aliceVisionShareDir, "fcn_resnet50.onnx") colorChartDetectionModelFolder = os.path.join(aliceVisionShareDir, "ColorChartDetectionModel") env = { "PATH": aliceVisionBinDir, "QT_PLUGIN_PATH": [qtPluginsDir], "QML2_IMPORT_PATH": [os.path.join(qtPluginsDir, "qml")] } for key, value in env.items(): logging.debug(f"Add to {key}: {value}") addToEnvPath(key, value, 0) # Add all available plugins if os.path.exists(pluginsDir): subfolders = [f.path for f in os.scandir(pluginsDir) if f.is_dir()] for plugin in subfolders: addToEnvPath("MESHROOM_PLUGINS_PATH", plugin, 0) variables = { "ALICEVISION_ROOT": aliceVisionDir, "ALICEVISION_SENSOR_DB": sensorDBPath, "ALICEVISION_VOCTREE": voctreePath, "ALICEVISION_SPHERE_DETECTION_MODEL": sphereDetectionModel, "ALICEVISION_SEMANTIC_SEGMENTATION_MODEL": semanticSegmentationModel, "ALICEVISION_COLORCHARTDETECTION_MODEL_FOLDER": colorChartDetectionModelFolder } for key, value in variables.items(): if key not in os.environ and os.path.exists(value): logging.debug(f"Set {key}: {value}") os.environ[key] = value # Add nodes and templates from AliceVision aliceVisionPluginDir = os.path.join(aliceVisionDir, "share", "meshroom") addToEnvPath("MESHROOM_NODES_PATH", aliceVisionPluginDir) addToEnvPath("MESHROOM_PIPELINE_TEMPLATES_PATH", aliceVisionPluginDir) addToEnvPath("PATH", os.environ.get("ALICEVISION_BIN_PATH", "")) if sys.platform == "win32": addToEnvPath("PATH", os.environ.get("ALICEVISION_LIBPATH", "")) else: addToEnvPath("LD_LIBRARY_PATH", os.environ.get("ALICEVISION_LIBPATH", "")) os.environ["QML_XHR_ALLOW_FILE_READ"] = '1' os.environ["QML_XHR_ALLOW_FILE_WRITE"] = '1' os.environ["PYSEQ_STRICT_PAD"] = '1' os.environ["QSG_RHI_BACKEND"] = "opengl" ================================================ FILE: meshroom/common/PySignal.py ================================================ # https://github.com/dgovil/PySignal __author__ = "Dhruv Govil" __copyright__ = "Copyright 2016, Dhruv Govil" __credits__ = ["Dhruv Govil", "John Hood", "Jason Viloria", "Adric Worley", "Alex Widener"] __license__ = "MIT" __version__ = "1.1.3" __maintainer__ = "Dhruv Govil" __email__ = "dhruvagovil@gmail.com" __status__ = "Beta" import inspect import sys import weakref from functools import partial from weakref import WeakMethod class Signal: """ The Signal is the core object that handles connection and emission . """ def __init__(self): super().__init__() self._block = False self._sender = None self._slots = [] def __call__(self, *args, **kwargs): self.emit(*args, **kwargs) def emit(self, *args, **kwargs): """ Calls all the connected slots with the provided args and kwargs unless block is activated """ if self._block: return def _get_sender(): """Try to get the bound, class or module method calling the emit.""" prev_frame = sys._getframe(2) func_name = prev_frame.f_code.co_name # Faster to try/catch than checking for 'self' try: return getattr(prev_frame.f_locals['self'], func_name) except KeyError: return getattr(inspect.getmodule(prev_frame), func_name) # Get the sender try: self._sender = WeakMethod(_get_sender()) # Account for when func_name is at '' except AttributeError: self._sender = None # Handle unsupported module level methods for WeakMethod. # TODO: Support module level methods. except TypeError: self._sender = None for slot in self._slots: if not slot: continue elif isinstance(slot, partial): slot() elif isinstance(slot, weakref.WeakKeyDictionary): # For class methods, get the class object and call the method accordingly. for obj, method in slot.items(): method(obj, *args, **kwargs) elif isinstance(slot, weakref.ref): # If it is a weakref, call the ref to get the instance and then call the func # Do not wrap in try/except so we do not risk masking exceptions from the actual func call tested_slot = slot() if tested_slot is not None: tested_slot(*args, **kwargs) else: # Else call it in a standard way. Should be just lambdas at this point slot(*args, **kwargs) def connect(self, slot): """ Connects the signal to any callable object """ if not callable(slot): raise ValueError(f"Connection to non-callable '{slot.__class__.__name__}' object failed") if isinstance(slot, (partial, Signal)) or '<' in slot.__name__: # If it is a partial, a Signal or a lambda. The '<' check is the only py2 and py3 compatible way I could find if slot not in self._slots: self._slots.append(slot) elif inspect.ismethod(slot): # Check if it is an instance method and store it with the instance as the key slotSelf = slot.__self__ slotDict = weakref.WeakKeyDictionary() slotDict[slotSelf] = slot.__func__ if slotDict not in self._slots: self._slots.append(slotDict) else: # If it is just a function then just store it as a weakref. newSlotRef = weakref.ref(slot) if newSlotRef not in self._slots: self._slots.append(newSlotRef) def disconnect(self, slot): """ Disconnects the slot from the signal """ if not callable(slot): return if inspect.ismethod(slot): # If it is a method, then find it by its instance slotSelf = slot.__self__ for s in self._slots: if (isinstance(s, weakref.WeakKeyDictionary) and (slotSelf in s) and (s[slotSelf] is slot.__func__)): self._slots.remove(s) break elif isinstance(slot, (partial, Signal)) or '<' in slot.__name__: # If it is a partial, a Signal or lambda, try to remove directly try: self._slots.remove(slot) except ValueError: pass else: # It's probably a function, so try to remove by weakref try: self._slots.remove(weakref.ref(slot)) except ValueError: pass def clear(self): """Clears the signal of all connected slots""" self._slots = [] def block(self, isBlocked): """Sets blocking of the signal""" self._block = bool(isBlocked) def sender(self): """Return the callable responsible for emitting the signal, if found.""" try: return self._sender() except TypeError: return None class ClassSignal: """ The class signal allows a signal to be set on a class rather than an instance. This emulates the behavior of a PyQt signal """ _map = {} def __get__(self, instance, owner): if instance is None: # When we access ClassSignal element on the class object without any instance, # we return the ClassSignal itself return self tmp = self._map.setdefault(self, weakref.WeakKeyDictionary()) return tmp.setdefault(instance, Signal()) def __set__(self, instance, value): raise RuntimeError("Cannot assign to a Signal object") class SignalFactory(dict): """ The Signal Factory object lets you handle signals by a string based name instead of by objects. """ def register(self, name, *slots): """ Registers a given signal :param name: the signal to register """ # setdefault initializes the object even if it exists. This is more efficient if name not in self: self[name] = Signal() for slot in slots: self[name].connect(slot) def deregister(self, name): """ Removes a given signal :param name: the signal to deregister """ self.pop(name, None) def emit(self, signalName, *args, **kwargs): """ Emits a signal by name if it exists. Any additional args or kwargs are passed to the signal :param signalName: the signal name to emit """ assert signalName in self, f"{signalName} is not a registered signal" self[signalName].emit(*args, **kwargs) def connect(self, signalName, slot): """ Connects a given signal to a given slot :param signalName: the signal name to connect to :param slot: the callable slot to register """ assert signalName in self, f"{signalName} is not a registered signal" self[signalName].connect(slot) def block(self, signals=None, isBlocked=True): """ Sets the block on any provided signals, or to all signals :param signals: defaults to all signals. Accepts either a single string or a list of strings :param isBlocked: the state to set the signal to """ if signals: try: if isinstance(signals, basestring): signals = [signals] except NameError: if isinstance(signals, str): signals = [signals] signals = signals or self.keys() for signal in signals: if signal not in self: raise RuntimeError(f"Could not find signal matching {signal}") self[signal].block(isBlocked) class ClassSignalFactory: """ The class signal allows a signal factory to be set on a class rather than an instance. """ _map = {} _names = set() def __get__(self, instance, owner): tmp = self._map.setdefault(self, weakref.WeakKeyDictionary()) signal = tmp.setdefault(instance, SignalFactory()) for name in self._names: signal.register(name) return signal def __set__(self, instance, value): raise RuntimeError("Cannot assign to a Signal object") def register(self, name): """ Registers a new signal with the given name :param name: The signal to register """ self._names.add(name) ================================================ FILE: meshroom/common/__init__.py ================================================ """ This module provides an abstraction around standard non-gui Qt notions (like Signal/Slot), so it can be used in python-only without the dependency to Qt. Warning: A call to `init(Backend.XXX)` is required to choose the backend before using this module. """ from enum import Enum class Backend(Enum): STANDALONE = 1 PYSIDE = 2 DictModel = None ListModel = None Slot = None Signal = None Property = None BaseObject = None Variant = None VariantList = None JSValue = None def init(backend): global DictModel, ListModel, Slot, Signal, Property, BaseObject, Variant, VariantList, JSValue if backend == Backend.PYSIDE: # PySide types from .qt import DictModel, ListModel, Slot, Signal, Property, BaseObject, Variant, VariantList, JSValue elif backend == Backend.STANDALONE: # Core types from .core import DictModel, ListModel, Slot, Signal, Property, BaseObject, Variant, VariantList, JSValue def strtobool(val: str): """Convert a string representation of truth to true (1) or false (0). True values are 'y', 'yes', 't', 'true', 'on', and '1'; false values are 'n', 'no', 'f', 'false', 'off', and '0'. Raises ValueError if 'val' is anything else. """ val = val.lower() if val in ('y', 'yes', 't', 'true', 'on', '1'): return 1 elif val in ('n', 'no', 'f', 'false', 'off', '0'): return 0 else: raise ValueError("invalid truth value %r" % (val,)) # Default initialization init(Backend.STANDALONE) ================================================ FILE: meshroom/common/core.py ================================================ from . import PySignal class CoreDictModel: def __init__(self, keyAttrName, **kwargs): self._objects = {} self._keyAttrName = keyAttrName def __len__(self): return len(self._objects) def __bool__(self): return bool(self._objects) def __iter__(self): """ Enables iteration over the list of objects. """ return iter(self._objects.values()) def keys(self): return self._objects.keys() def items(self): return self._objects.items() def values(self): return self._objects.values() @property def objects(self): return self._objects def get(self, key): """ :param key: :return: the value or None if not found """ return self._objects.get(key) def getr(self, key): """ Get or raise an error if the key does not exists. :param key: :return: the value """ return self._objects[key] def add(self, obj): key = getattr(obj, self._keyAttrName, None) assert key is not None assert key not in self._objects self._objects[key] = obj def rename(self, oldKey: str, newKey: str): """ Rename an element in the dict model Args: oldKey (str): Previous key name of the element to replace. newKey (str): New key name to insert in the model. Raises: KeyError: if the new name is already used. """ if newKey in self._objects.keys(): raise KeyError(f"Key {newKey} is already in use in {self}") obj = self._objects[oldKey] self._objects[newKey] = obj del self._objects[oldKey] def pop(self, key): assert key in self._objects return self._objects.pop(key) def remove(self, obj): assert obj in self._objects.values() del self._objects[getattr(obj, self._keyAttrName)] def clear(self): self._objects.clear() def update(self, objects): for obj in objects: self.add(obj) def reset(self, objects): self.clear() self.update(objects) class CoreListModel: def __init__(self, parent=None): self._objects = [] def __iter__(self): return self._objects.__iter__() def __len__(self): return len(self._objects) def __getitem__(self, idx): return self._objects[idx] def values(self): return self._objects def setObjectList(self, iterable): self.clear() self._objects = iterable def at(self, idx): return self._objects[idx] def append(self, obj): self._objects.append(obj) def extend(self, iterable): self._objects.extend(iterable) def indexOf(self, obj): return self._objects.index(obj) def removeAt(self, idx, count=1): del self._objects[idx:idx+count] def remove(self, obj): self._objects.remove(obj) def clear(self): self._objects = [] def insert(self, index, iterable): self._objects[index:index] = iterable def CoreSlot(*args, **kwargs): def slot_decorator(func): def func_wrapper(*f_args, **f_kwargs): return func(*f_args, **f_kwargs) return func_wrapper return slot_decorator class CoreProperty(property): def __init__(self, ptype, fget=None, fset=None, **kwargs): super().__init__(fget, fset) class CoreObject: def __init__(self, parent=None, *args, **kwargs): super().__init__() self._parent = parent # Note: we do not use ClassSignal, as it can not be used in __del__. self.destroyed = PySignal.Signal() def __del__(self): self.destroyed.emit() def parent(self): return self._parent DictModel = CoreDictModel ListModel = CoreListModel Slot = CoreSlot Signal = PySignal.ClassSignal Property = CoreProperty BaseObject = CoreObject Variant = object VariantList = object JSValue = None ================================================ FILE: meshroom/common/deprecated.py ================================================ """Utilities for marking function parameters as deprecated.""" import warnings import logging def depreciateParam(paramToDepreciate, msg): """Decorator factory that emits a deprecation warning when a specific keyword argument is used. Use this to gracefully phase out function parameters by warning callers that a particular keyword argument is deprecated, while still allowing the decorated function to execute normally. Args: paramToDepreciate (str): The name of the keyword argument to flag as deprecated. msg (str): A warning message template that will be formatted with the keyword arguments passed to the decorated function (using ``str.format(**kwargs)``). Returns: callable: A decorator that wraps the target function with deprecation checks. Example: >>> @depreciateParam("oldArg", "'{oldArg}' is deprecated, use 'newArg' instead") ... def my_func(newArg=None, oldArg=None): ... pass >>> my_func(oldArg="value") # emits DeprecationWarning """ def decorator(function): def wrapper(*args, **kwargs): if paramToDepreciate in kwargs.keys(): warnings.warn(msg.format(**kwargs), DeprecationWarning) logging.warn(DeprecationWarning(msg.format(**kwargs))) return function(*args, **kwargs) return wrapper return decorator ================================================ FILE: meshroom/common/qt.py ================================================ from PySide6 import QtCore, QtQml import shiboken6 class QObjectListModel(QtCore.QAbstractListModel): """ QObjectListModel provides a more powerful, but still easy to use, alternative to using QObjectList lists as models for QML views. As a QAbstractListModel, it has the ability to automatically notify the view of specific changes to the list, such as adding or removing items. At the same time it provides QList-like convenience functions such as append, at, and removeAt for easily working with the model from Python. """ ObjectRole = QtCore.Qt.UserRole def __init__(self, keyAttrName='', parent=None): """ Constructs an object list model with the given parent. """ super().__init__(parent) self._objects = list() # Internal list of objects self._keyAttrName = keyAttrName self._objectByKey = {} self.roles = QtCore.QAbstractListModel.roleNames(self) self.roles[self.ObjectRole] = b"object" self.requestDeletion.connect(self.onRequestDeletion, QtCore.Qt.QueuedConnection) def roleNames(self): return self.roles def __iter__(self): """ Enables iteration over the list of objects. """ return iter(self._objects) def keys(self): return self._objectByKey.keys() def items(self): return self._objectByKey.items() def __len__(self): return self.size() def __bool__(self): return self.size() > 0 def __getitem__(self, index): """ Enables the [] operator. Only accepts index (integer). """ return self._objects[index] def data(self, index, role): """ Returns data for the specified role, from the item with the given index. The only valid role is ObjectRole. If the view requests an invalid index or role, an invalid variant is returned. """ if index.row() < 0 or index.row() >= len(self._objects): return None return self._objects[index.row()] def rowCount(self, parent): """ Returns the number of rows in the model. This value corresponds to the number of items in the model's internal object list. """ return self.size() def objectList(self): """ Returns the object list used by the model to store data. """ return self._objects def values(self): return self._objects def setObjectList(self, objects): """ Sets the model's internal objects list to objects. The model will notify any attached views that its underlying data has changed. """ oldSize = self.size() self.beginResetModel() for obj in self._objects: self._dereferenceItem(obj) self._objects = objects for obj in self._objects: self._referenceItem(obj) self.endResetModel() self.dataChanged.emit(self.index(0), self.index(self.size() - 1), []) if self.size() != oldSize: self.countChanged.emit() # ###### # BaseModel API # ###### @property def objects(self): return self._objectByKey @QtCore.Slot(str, result=QtCore.QObject) def get(self, key): """ :param key: :return: the value or None if not found """ return self._objectByKey.get(key) @QtCore.Slot(str, result=QtCore.QObject) def getr(self, key): """ Get or raise an error if the key does not exists. :param key: :return: the value """ return self._objectByKey[key] def add(self, obj): self.append(obj) def pop(self, key): obj = self.get(key) self.remove(obj) return obj ############ # List API # ############ @QtCore.Slot(QtCore.QObject) def append(self, obj): """ Insert object at the end of the model. """ self.extend([obj]) def extend(self, iterable): """ Insert objects at the end of the model. """ self.beginInsertRows(QtCore.QModelIndex(), self.size(), self.size() + len(iterable) - 1) [self._referenceItem(obj) for obj in iterable] self._objects.extend(iterable) self.endInsertRows() self.countChanged.emit() def insert(self, i, toInsert): """ Inserts object(s) at index position i in the model and notifies any views. If i is 0, the object is prepended to the model. If i is size(), the object is appended to the list. Accepts both QObject and list of QObjects. """ if not isinstance(toInsert, list): toInsert = [toInsert] self.beginInsertRows(QtCore.QModelIndex(), i, i + len(toInsert) - 1) for obj in reversed(toInsert): self._referenceItem(obj) self._objects.insert(i, obj) self.endInsertRows() self.countChanged.emit() @QtCore.Slot(int, result=QtCore.QObject) def at(self, i): """ Return the object at index i. """ return self._objects[i] def replace(self, i, obj): """ Replaces the item at index position i with object and notifies any views. i must be a valid index position in the list (i.e., 0 <= i < size()). """ self._dereferenceItem(self._objects[i]) self._referenceItem(obj) self._objects[i] = obj self.dataChanged.emit(self.index(i), self.index(i), []) def rename(self, oldKey: str, newKey: str): """ Rename an element in the model Args: oldKey (str): Previous key name of the element to replace. newKey (str): New key name to insert in the model. Raises: KeyError: if the new name is already used. """ if newKey in self._objectByKey.keys(): raise KeyError(f"Key {newKey} is already in use in {self}") obj = self._objectByKey[oldKey] index = self.indexOf(obj) self._objectByKey[newKey] = obj del self._objectByKey[oldKey] self.dataChanged.emit(self.index(index), self.index(index), []) def move(self, fromIndex, toIndex): """ Moves the item at index position from to index position to and notifies any views. This function assumes that both from and to are at least 0 but less than size(). To avoid failure, test that both from and to are at least 0 and less than size(). """ value = toIndex if toIndex > fromIndex: value += 1 if not self.beginMoveRows(QtCore.QModelIndex(), fromIndex, fromIndex, QtCore.QModelIndex(), value): return self._objects.insert(toIndex, self._objects.pop(fromIndex)) self.endMoveRows() def removeAt(self, i, count=1): """ Removes count number of items from index position i and notifies any views. i must be a valid index position in the model (i.e., 0 <= i < size()), as must as i + count - 1. """ self.beginRemoveRows(QtCore.QModelIndex(), i, i + count - 1) for cpt in range(count): obj = self._objects.pop(i) self._dereferenceItem(obj) self.endRemoveRows() self.countChanged.emit() @QtCore.Slot(QtCore.QObject) def remove(self, obj): """ Removes the first occurrence of the given object. Raises a ValueError if not in list. """ if not self.contains(obj): raise ValueError("QObjectListModel.remove(obj) : obj not in list") self.removeAt(self.indexOf(obj)) def takeAt(self, i): """ Removes the item at index position i (notifying any views) and returns it. i must be a valid index position in the model (i.e., 0 <= i < size()). """ self.beginRemoveRows(QtCore.QModelIndex(), i, i) obj = self._objects.pop(i) self._dereferenceItem(obj) self.endRemoveRows() self.countChanged.emit() return obj def clear(self): """ Removes all items from the model and notifies any views. """ if not self._objects: return self.beginResetModel() for obj in self._objects: self._dereferenceItem(obj) self._objects = [] self.endResetModel() self.countChanged.emit() def update(self, objects): self.extend(objects) def reset(self, objects): self.setObjectList(objects) @QtCore.Slot(QtCore.QObject, result=bool) def contains(self, obj): """ Returns true if the list contains an occurrence of object; otherwise returns false. """ return obj in self._objects @QtCore.Slot(QtCore.QObject, result=int) def indexOf(self, matchObj, fromIndex=0, positive=True): """ Returns the index position of the first occurrence of object in the model, searching forward from index position from. If positive is True, will always return a positive index. """ index = self._objects[fromIndex:].index(matchObj) + fromIndex if positive and index < 0: index += self.size() return index def lastIndexOf(self, matchObj, fromIndex=-1, positive=True): """ Returns the index position of the last occurrence of object in the list, searching backward from index position from. If from is -1 (the default), the search starts at the last item. If positive is True, will always return a positive index. """ r = list(self._objects) r.reverse() index = - r[-fromIndex - 1:].index(matchObj) + fromIndex if positive and index < 0: index += self.size() return index def size(self): """ Returns the number of items in the model. """ return len(self._objects) @QtCore.Slot(result=bool) def isEmpty(self): """ Returns true if the model contains no items; otherwise returns false. """ return len(self._objects) == 0 def _referenceItem(self, item): if not item.parent(): # Take ownership of the object if not already parented item.setParent(self) if not self._keyAttrName: return key = getattr(item, self._keyAttrName, None) if key is None: return if key in self._objectByKey: raise ValueError(f"Object key {self._keyAttrName}:{key} is not unique") self._objectByKey[key] = item @QtCore.Slot(int, result=QtCore.QModelIndex) def index(self, row: int, column: int = 0, parent=QtCore.QModelIndex()): """ Returns the model index for the given row, column and parent index. """ if parent.isValid() or column != 0: return QtCore.QModelIndex() if row < 0 or row >= self.size(): return QtCore.QModelIndex() return self.createIndex(row, column, self._objects[row]) def _dereferenceItem(self, item): # Ask for object deletion if parented to the model if shiboken6.isValid(item) and item.parent() == self: # delay deletion until the next event loop # This avoids warnings when the QML engine tries to evaluate (but should not) # an object that has already been deleted self.requestDeletion.emit(item) if not self._keyAttrName: return key = getattr(item, self._keyAttrName, None) if key is None: return if key not in self._objectByKey: raise RuntimeError(f"{key} is not in the Model: {self._objectByKey.keys()}") del self._objectByKey[key] def onRequestDeletion(self, item): item.deleteLater() countChanged = QtCore.Signal() count = QtCore.Property(int, size, notify=countChanged) requestDeletion = QtCore.Signal(QtCore.QObject) class QTypedObjectListModel(QObjectListModel): """ Typed QObjectListModel that exposes T properties as roles """ # TODO: handle notify signal to emit dataChanged signal def __init__(self, keyAttrName="name", T=QtCore.QObject, parent=None): super().__init__(keyAttrName, parent) self._T = T blacklist = ["id", "index", "class", "model", "modelData"] self._metaObject = T.staticMetaObject propCount = self._metaObject.propertyCount() role = self.ObjectRole + 1 for i in range(0, propCount): prop = self._metaObject.property(i) if not prop.name() in blacklist: self.roles[role] = prop.name() role += 1 else: print("Reserved role name: " + prop.name()) def data(self, index, role): obj = super().data(index, self.ObjectRole) if role == self.ObjectRole: return obj if obj: return obj.property(self.roles[role]) return None def roleForName(self, name): roles = [role for role, value in self.roles.items() if value == name] return roles[0] if roles else -1 def _referenceItem(self, item): if item.staticMetaObject != self._metaObject: raise TypeError("Invalid object type: expected {}, got {}".format( self._metaObject.className(), item.staticMetaObject.className())) super()._referenceItem(item) class SortedModelByReference(QtCore.QSortFilterProxyModel): """ Sort a source model based on the ordered list (reference) of the same elements. This proxy is useful if the model needs to be sorted a certain way for a specific use. """ def __init__(self, parent): super().__init__(parent) self._reference = [] def setReference(self, iterable): """ Set the reference ordered list """ self._reference = iterable self.sort() def reference(self): return self._reference def lessThan(self, left, right): l = self.sourceModel().data(left, QObjectListModel.ObjectRole) r = self.sourceModel().data(right, QObjectListModel.ObjectRole) if l not in self._reference: return False if r not in self._reference: return True return self._reference.index(l) < self._reference.index(r) def sort(self): """ Sort the proxy and call invalidate() """ super().sort(0, QtCore.Qt.AscendingOrder) self.invalidate() DictModel = QObjectListModel ListModel = QObjectListModel Slot = QtCore.Slot Signal = QtCore.Signal Property = QtCore.Property BaseObject = QtCore.QObject Variant = "QVariant" VariantList = "QVariantList" JSValue = QtQml.QJSValue ================================================ FILE: meshroom/core/__init__.py ================================================ from contextlib import contextmanager import hashlib import importlib import inspect import logging import os from pathlib import Path import pkgutil import sys import traceback import uuid try: # for cx_freeze import encodings.ascii import encodings.idna import encodings.utf_8 except Exception: pass from meshroom.core.plugins import NodePlugin, NodePluginManager, Plugin, processEnvFactory, formatNodeDescriptionErrorMessage from meshroom.core.submitter import BaseSubmitter from meshroom.env import EnvVar, meshroomFolder from . import desc from .desc import MrNodeType # Setup logging logging.basicConfig(format='[%(asctime)s][%(levelname)s] %(message)s', level=logging.INFO) # make a UUID based on the host ID and current time sessionUid = str(uuid.uuid1()) cacheFolderName = 'MeshroomCache' pluginManager: NodePluginManager = NodePluginManager() submitters: dict[str, BaseSubmitter] = {} pipelineTemplates: dict[str, str] = {} def hashValue(value) -> str: """ Hash 'value' using sha1. """ hashObject = hashlib.sha1(str(value).encode('utf-8')) return hashObject.hexdigest() @contextmanager def add_to_path(p): import sys old_path = sys.path sys.path = sys.path[:] sys.path.insert(0, p) try: yield finally: sys.path = old_path def loadClasses(folder: str, packageName: str, classType: type) -> list[type]: """ Go over the Python module named "packageName" located in "folder" to find files that contain classes of type "classType" and return these classes in a list. Args: folder: the folder to load the module from. packageName: the name of the module to look for nodes in. classType: the class to look for in the files that are inspected. """ classes = [] errors = [] resolvedFolder = str(Path(folder).resolve()) # temporarily add folder to python path with add_to_path(resolvedFolder): # import node package try: package = importlib.import_module(packageName) packageName = package.packageName if hasattr(package, "packageName") \ else package.__name__ packagePath = os.path.dirname(package.__file__) except Exception as exc: tb = traceback.extract_tb(exc.__traceback__) last_call = tb[-1] logging.warning(f' * Failed to load package "{packageName}" from folder "{resolvedFolder}" ({type(exc).__name__}): {str(exc)}\n' # filename:lineNumber functionName f'{last_call.filename}:{last_call.lineno} {last_call.name}\n' # line of code with the error f'{last_call.line}' # Full traceback f'\n{traceback.format_exc()}\n\n' ) return [] for _, pluginName, _ in pkgutil.iter_modules(package.__path__): pluginModuleName = "." + pluginName try: pluginMod = importlib.import_module(pluginModuleName, package=package.__name__) plugins = [plugin for _, plugin in inspect.getmembers(pluginMod, inspect.isclass) if plugin.__module__ == f"{package.__name__}.{pluginName}" and issubclass(plugin, classType)] if not plugins: # Only packages/folders have __path__, single module/file do not have it. isPackage = hasattr(pluginMod, "__path__") # Sub-folders/Packages should not raise a warning if not isPackage: logging.warning(f"No class defined in plugin: {package.__name__}.{pluginName} ('{pluginMod.__file__}')") for p in plugins: p.packageName = packageName p.packagePath = packagePath if classType == desc.BaseNode: nodePlugin = NodePlugin(p) if nodePlugin.errors: explicitErrors = [] for err in nodePlugin.errors: explicitErrors.append(f"\n\t - {formatNodeDescriptionErrorMessage(err)}") errors.append(f" * {pluginName}: The following parameters have issues: {''.join(explicitErrors)}") classes.append(nodePlugin) else: classes.append(p) except Exception as exc: if classType == BaseSubmitter: logging.warning(f" Could not load submitter {pluginName} from package '{package.__name__}'\n{exc}") else: tb = traceback.extract_tb(exc.__traceback__) last_call = tb[-1] errors.append(f' * {pluginName} ({type(exc).__name__}): {exc}\n' # filename:lineNumber functionName f'{last_call.filename}:{last_call.lineno} {last_call.name}\n' # line of code with the error f'{last_call.line}' # Full traceback f'\n{traceback.format_exc()}\n\n' ) if errors: logging.warning(' The following "{package}" plugins could not be loaded:\n' '{errorMsg}\n' .format(package=packageName, errorMsg='\n'.join(errors))) return classes def loadClassesNodes(folder: str, packageName: str) -> list[NodePlugin]: """ Return the list of all the NodePlugins that were created following the search of the Python module named "packageName" located in the folder "folder". A NodePlugin is created when a file within "packageName" that contains a class inheriting desc.BaseNode is found. Args: folder: the folder to load the module from. packageName: the name of the module to look for nodes in. Returns: list[NodePlugin]: a list of all the NodePlugins that were created based on the module's search. If none has been created, an empty list is returned. """ return loadClasses(folder, packageName, desc.BaseNode) def loadClassesSubmitters(folder: str, packageName: str) -> list[BaseSubmitter]: """ Return the list of all the submitters that were found during the search of the Python module named "packageName" that located in the folder "folder". A submitter is found if a file within "packageName" contains a class inheriting from BaseSubmitter. Args: folder: the folder to load the module from. packageName: the name of the module to look for nodes in. Returns: list[BaseSubmitter]: a list of all the submitters that were found during the module's search """ return loadClasses(folder, packageName, BaseSubmitter) class Version: """ Version provides convenient properties and methods to manipulate and compare versions. """ def __init__(self, *args): """ Args: *args (convertible to int): version values """ if len(args) == 0: self.components = tuple() self.status = '' elif len(args) == 1: versionName = args[0] if isinstance(versionName, str): self.components, self.status = Version.toComponents(versionName) elif isinstance(versionName, (list, tuple)): self.components = tuple([int(v) for v in versionName]) self.status = '' else: raise RuntimeError("Version: Unsupported input type.") else: self.components = tuple([int(v) for v in args]) self.status = '' def __repr__(self): return self.name def __neg__(self): return not self.name def __len__(self): return len(self.components) def __eq__(self, other): """ Test equality between 'self' with 'other'. Args: other (Version): the version to compare to Returns: bool: whether the versions are equal """ return self.name == other.name def __lt__(self, other): """ Test 'self' inferiority to 'other'. Args: other (Version): the version to compare to Returns: bool: whether self is inferior to other """ return self.components < other.components def __le__(self, other): """ Test 'self' inferiority or equality to 'other'. Args: other (Version): the version to compare to Returns: bool: whether self is inferior or equal to other """ return self.components <= other.components @staticmethod def toComponents(versionName): """ Split 'versionName' as a tuple of individual components, including its status if there is any. Args: versionName (str): version name Returns: tuple of int, string: split version numbers, status if any (or empty string) """ if not versionName: return (), '' status = '' # If there is a status, it is placed after a "-" (up to Meshroom 2025.1.0) or a "+" versionName = versionName.replace("-", "+") # Keep compatibility for scenes created with 2025.1.0 or older splitComponents = versionName.split("+", maxsplit=1) # If there is no status, splitComponents is equal to [versionName] if len(splitComponents) > 1: status = splitComponents[-1] return tuple([int(v) for v in splitComponents[0].split(".")]), status @property def name(self): """ Version major number. """ return ".".join([str(v) for v in self.components]) @property def major(self): """ Version major number. """ return self.components[0] @property def minor(self): """ Version minor number. """ if len(self) < 2: return 0 return self.components[1] @property def micro(self): """ Version micro number. """ if len(self) < 3: return 0 return self.components[2] def moduleVersion(moduleName: str, default=None): """ Return the version of a module indicated with '__version__' keyword. Args: moduleName (str): the name of the module to get the version of default: the value to return if no version info is available Returns: str: the version of the module """ return getattr(sys.modules[moduleName], "__version__", default) def nodeVersion(nodeDesc: desc.Node, default=None): """ Return node type version for the given node description class. Args: nodeDesc (desc.Node): the node description class default: the value to return if no version info is available Returns: str: the version of the node type """ return moduleVersion(nodeDesc.__module__, default) def loadNodes(folder, packageName) -> list[NodePlugin]: if not os.path.isdir(folder): logging.error(f"Node folder '{folder}' does not exist.") return [] nodes = loadClassesNodes(folder, packageName) return nodes def loadAllNodes(folder) -> list[Plugin]: plugins = [] for _, package, ispkg in pkgutil.iter_modules([folder]): if ispkg: plugin = Plugin(package, folder) nodePlugins = loadNodes(folder, package) if nodePlugins: for node in nodePlugins: plugin.addNodePlugin(node) nodesStr = ', '.join([node.nodeDescriptor.__name__ for node in nodePlugins]) logging.debug(f'Nodes loaded [{package}]: {nodesStr}') plugins.append(plugin) return plugins def loadPluginFolder(folder) -> list[Plugin]: if not os.path.isdir(folder): logging.info(f"Plugin folder '{folder}' does not exist.") return mrFolder = Path(folder, 'meshroom') if not mrFolder.exists(): logging.info(f"Plugin folder '{folder}' does not contain a 'meshroom' folder.") return plugins = loadAllNodes(folder=mrFolder) if plugins: for plugin in plugins: pluginManager.addPlugin(plugin) pipelineTemplates.update(plugin.templates) return plugins def loadPluginsFolder(folder): if not os.path.isdir(folder): logging.debug(f"PluginSet folder '{folder}' does not exist.") return for file in os.listdir(folder): if os.path.isdir(file): subFolder = os.path.join(folder, file) loadPluginFolder(subFolder) def registerSubmitter(s: BaseSubmitter): if s.name in submitters: logging.error(f"Submitter {s.name} is already registered.") submitters[s.name] = s def loadSubmitters(folder, packageName) -> list[BaseSubmitter]: if not os.path.isdir(folder): logging.error(f"Submitters folder '{folder}' does not exist.") return return loadClassesSubmitters(folder, packageName) def loadAllSubmitters(folder) -> list[BaseSubmitter]: submitters = [] for _, package, ispkg in pkgutil.iter_modules([folder]): if ispkg: subs = loadSubmitters(folder, package) if subs: submitters.extend(subs) return submitters 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) def initNodes(): additionalNodesPath = EnvVar.getList(EnvVar.MESHROOM_NODES_PATH) nodesFolders = [os.path.join(meshroomFolder, "nodes")] + additionalNodesPath for f in nodesFolders: plugins = loadAllNodes(folder=f) if plugins: for plugin in plugins: pluginManager.addPlugin(plugin) def initSubmitters(): """ Detect and register submitter plugins Note: Make sure the package name (folder inside the additionalPaths folders) are unique : so we cannot name them "submitters" because it is already taken by the submitters package inside meshroom """ # Load submitters submitterPaths = EnvVar.getList(EnvVar.MESHROOM_SUBMITTERS_PATH) for folder in submitterPaths: subs = loadAllSubmitters(folder) for sub in subs: registerSubmitter(sub()) def initPipelines(): # Load pipeline templates: check in the default folder and any folder the user might have # added to the environment variable pipelineTemplatesFolders = EnvVar.getList(EnvVar.MESHROOM_PIPELINE_TEMPLATES_PATH) for f in pipelineTemplatesFolders: loadPipelineTemplates(f) for plugin in pluginManager.getPlugins().values(): pipelineTemplates.update(plugin.templates) def initPlugins(): # Classic plugins (with a DirTreeProcessEnv) additionalPluginsPath = EnvVar.getList(EnvVar.MESHROOM_PLUGINS_PATH) pluginsFolders = [os.path.join(meshroomFolder, "plugins")] + additionalPluginsPath for f in pluginsFolders: plugins = loadPluginFolder(folder=f) # Set the ProcessEnv for each plugin if plugins: for plugin in plugins: plugin.processEnv = processEnvFactory(f, plugin.configEnv) # Rez plugins (with a RezProcessEnv) rezPlugins = initRezPlugins() def initRezPlugins(): rezPlugins = {} rezList = EnvVar.getList(EnvVar.MESHROOM_REZ_PLUGINS) for p in rezList: name, path = p.split("=") rezPlugins[name] = path # "name" is the name of the Rez package plugins = loadPluginFolder(folder=path) # Set the ProcessEnv for Rez plugins if plugins: for plugin in plugins: plugin.processEnv = processEnvFactory(path, plugin.configEnv, envType="rez", uri=name) return rezPlugins ================================================ FILE: meshroom/core/attribute.py ================================================ #!/usr/bin/env python from __future__ import annotations import copy import os import re import weakref import logging import inspect from collections.abc import Iterable, Sequence from string import Template from meshroom.common import BaseObject, Property, Variant, Signal, ListModel, DictModel, Slot from meshroom.core import desc, hashValue from meshroom.core.keyValues import KeyValues from meshroom.core.exception import InvalidEdgeError from typing import TYPE_CHECKING, Optional if TYPE_CHECKING: from meshroom.core.graph import Edge def attributeFactory(description: str, value, isOutput: bool, node, root=None, parent=None): """ Create an Attribute based on description type. Args: description: the Attribute description value: value of the Attribute. Will be set if not None. isOutput: whether the Attribute is an output attribute. node (Node): node owning the Attribute. Note that the created Attribute is not added to \ Node's attributes root: (optional) parent Attribute (must be ListAttribute or GroupAttribute) parent (BaseObject): (optional) the parent BaseObject if any """ attr: Attribute = description.instanceType(node, description, isOutput, root, parent) if value is not None: attr._setValue(value) else: attr.resetToDefaultValue() # Only connect slot that reacts to value change once initial value has been set. # NOTE: This should be handled by the Node class, but we are currently limited by our core # signal implementation that does not support emitting parameters. # And using a lambda here to send the attribute as a parameter causes # performance issues when using the pyside backend. attr.valueChanged.connect(attr._onValueChanged) return attr class Attribute(BaseObject): """ """ LINK_EXPRESSION_REGEX = re.compile(r'^\{[A-Za-z]+[A-Za-z0-9_.\[\]]*\}$') VALID_IMAGE_SEMANTICS = ["image", "imageList", "sequence"] VALID_3D_EXTENSIONS = [".obj", ".stl", ".fbx", ".gltf", ".abc", ".ply"] VALID_TEXT_EXTENSIONS = [".txt", ".json", ".log", ".csv", ".md"] @staticmethod def isLinkExpression(value) -> bool: """ Return whether the given argument is a link expression. A link expression is a string matching the {nodeName.attrName} pattern. """ return isinstance(value, str) and Attribute.LINK_EXPRESSION_REGEX.match(value) def __init__(self, node, attributeDesc: desc.Attribute, isOutput: bool, root=None, parent=None): """ Attribute constructor Args: node (Node): the Node hosting this Attribute attributeDesc: the description of this Attribute isOutput: whether this Attribute is an output of the Node root (Attribute): (optional) the root Attribute (List or Group) containing this one parent (BaseObject): (optional) the parent BaseObject """ super().__init__(parent) self._root = None if root is None else weakref.ref(root) self._node = weakref.ref(node) self._desc: desc.Attribute = attributeDesc self._isOutput: bool = isOutput self._enabled: bool = True self._depth: int = root.depth + 1 if root is not None else 0 self._exposed: bool = root.exposed if root is not None else attributeDesc.exposed self._invalidate = False if self._isOutput else attributeDesc.invalidate self._invalidationValue = "" # invalidation value for output attributes self._value = None self._keyValues = None # list of pairs (key, value) for keyable attribute self._linkExpression: Optional[str] = None self._initValue() def _getFullName(self) -> str: """ Get the attribute name following the path from the node to the attribute. Return: nodeName.groupName.subGroupName.name """ return f'{self.node.name}.{self._getRootName()}' def _getRootName(self) -> str: """ Get the attribute name following the path from the root attribute. Return: groupName.subGroupName.name """ if isinstance(self.root, ListAttribute): return f'{self.root.rootName}[{self.root.index(self)}]' elif isinstance(self.root, GroupAttribute): return f'{self.root.rootName}.{self._desc.name}' return self._desc.name def asLinkExpr(self) -> str: """ Return the link expression for this Attribute. """ return "{" + self._getFullName() + "}" def requestGraphUpdate(self): if self.node.graph: self.node.graph.markNodesDirty(self.node) self.node.graph.update() def requestNodeUpdate(self): # Update specific node information that do not affect the rest of the graph # (like internal attributes) if self.node: self.node.updateInternalAttributes() def executeValue(self, value): """ Assume value is a callable Analyze value signature to detect if we want to use node or attr as parameter. This method may be removed when all the legacy code is transformed. Args: value (Callable): the callable to execute Return the result value of the callable """ # The new behavior is to provide the node to the callable. # For compatibility with the old behavior providing the attribute, we check if the attribute is named "attr" and provide the attribute. params = inspect.signature(value).parameters if len(params) == 1 and list(params)[0] == "attr": return value(self) return value(self.node) def _initValue(self): """ Initialize the attribute value. Called in the attribute factory for each attributes. """ if self._desc.keyable: # Keyable attribute, initialize keyValues from attribute description self._keyValues = KeyValues(self._desc) # Send signal and updates if keyValues changed self._keyValues.pairsChanged.connect(self._onKeyValuesChanged) elif self._desc._valueType is not None: self._value = self._desc._valueType() def _getEvalValue(self): """ Return the value of a the attribute. For string, expressions will be evaluated. """ if isinstance(self.value, str): env = self.node.nodePlugin.configFullEnv if self.node.nodePlugin else os.environ substituted = Template(self.value).safe_substitute(env) try: varResolved = substituted.format(**self.node._expVars, **self.node._staticExpVars) return varResolved except (KeyError, IndexError): # Catch KeyErrors and IndexErros to be able to open files created prior to the # support of relative variables (when self.node._expVars was not used to evaluate # expressions in the attribute) return substituted except (ValueError): return "" return self.value def _getValue(self): """ Return the value of the attribute or the linked attribute value. """ if self.keyable: raise RuntimeError(f"Cannot get value of {self._getFullName()}, the attribute is keyable.") if self.isLink: return self._getInputLink().value return self._value def _setValue(self, value): """ Set the attribute value from a given value, a given function or a given attribute. """ if self._value == value: return if self._handleLinkValue(value): if self.keyable: self._keyValues.reset() return elif self.keyable and isinstance(value, dict): # keyable attribute initialize from a dict self.keyValues.resetFromDict(value) elif self.keyable: # keyable attribute but value is not a dict raise RuntimeError(f"Cannot set value of {self._getFullName()}, the attribute is keyable.") elif callable(value): # evaluate the function self._value = self.executeValue(value) else: # if we set a new value, we use the attribute descriptor validator to check the # validity of the value and apply some conversion if needed convertedValue = self.validateValue(value) self._value = convertedValue self.expressionApplied.emit() # Request graph update when input parameter value is set # and parent node belongs to a graph # Output attributes value are set internally during the update process, # which is why we do not trigger any update in this case # TODO: update only the nodes impacted by this change # TODO: only update the graph if this attribute participates to a UID if self.isInput: self.requestGraphUpdate() # TODO: only call update of the node if the attribute is internal # Internal attributes are set as inputs self.requestNodeUpdate() self.valueChanged.emit() def _getKeyValues(self): """ Return the per-key values object of the attribute or of the linked attribute. """ if not self.keyable: raise RuntimeError(f"Cannot get keyValues of {self._getFullName()}, the attribute is not keyable.") if self.isLink: return self._getInputLink().keyValues return self._keyValues def _handleLinkValue(self, value) -> bool: """ Handle the assignment of a link if `value` is a serialized link expression or an in-memory Attribute reference. Returns: True if the value has been handled as a link, False otherwise. """ isAttribute = isinstance(value, Attribute) isLinkExpression = Attribute.isLinkExpression(value) if not isAttribute and not isLinkExpression: return False if isAttribute: self._linkExpression = value.asLinkExpr() # If the value is a direct reference to an attribute, it can directly # be converted to an edge as the source attribute already exists in # memory. self._applyExpr() elif isLinkExpression: self._linkExpression = value return True def _applyExpr(self): """ For string parameters with an expression (when loaded from file), this function convert the expression into a real edge in the graph and clear the string value. """ if not self.isInput or not self._linkExpression: return if not (graph := self.node.graph): return link = self._linkExpression[1:-1] linkNodeName, linkAttrName = "", "" try: linkNodeName, linkAttrName = link.split(".", 1) except ValueError as err: logging.warning('Retrieve Connected Attribute from Expression failed.') logging.warning(f'Expression: "{link}"\nError: "{err}".') try: node = graph.node(linkNodeName) if node is None: raise InvalidEdgeError(self.fullName, link, "Source node does not exist.") attr = node.attribute(linkAttrName) if attr is None: raise InvalidEdgeError(self.fullName, link, "Source attribute does not exist.") attr.connectTo(self) except InvalidEdgeError as err: logging.warning(err) except Exception as err: logging.warning("An unexpected error happened during edge creation.") logging.warning(f"Expression '{self._linkExpression}': {err}.") self._linkExpression = None self.resetToDefaultValue() def resetToDefaultValue(self): """ Reset the attribute to its default value. """ if self.keyable: self._value = None self._keyValues.reset() else: self._setValue(copy.copy(self.getDefaultValue())) def getDefaultValue(self): """ Get the attribute default value. """ if callable(self._desc.value): try: return self.executeValue(self._desc.value) except Exception as exc: if not self.node.isCompatibilityNode: logging.warning(f"Failed to evaluate 'defaultValue' (node lambda) for attribute '{self.fullName}': {exc}") return None # keyable attribute default value if self.keyable: return {} # If the node's desc value is None and this is an input attribute with a known value type, # return the type's default value instead of None if self._desc.value is None and not self._isOutput and self._desc._valueType is not None: return self._desc._valueType() # Need to force a copy, for the case where the value is a list # (avoid reference to the desc value) return copy.copy(self._desc.value) def getSerializedValue(self): """ Get the attribute value serialized. """ if self.isLink: return self._getInputLink().asLinkExpr() if self.keyable: return self._keyValues.getSerializedValues() if self.isOutput and self._desc.isExpression: return self.getDefaultValue() return self.value def getPrimitiveValue(self, exportDefault=True): return self._value def getValueStr(self, withQuotes=True) -> str: """ Return the value formatted as a string with quotes to deal with spaces. If it is a string, expressions will be evaluated. If it is an empty string, it will returns 2 quotes. If it is an empty list, it will returns a really empty string. If it is a list with one empty string element, it will returns 2 quotes. """ # Keyable attribute, for now return the list of pairs as a JSON sting if self.keyable: return self._keyValues.getJson() # ChoiceParam with multiple values should be combined if isinstance(self._desc, desc.ChoiceParam) and not self._desc.exclusive: # Ensure value is a list as expected assert (isinstance(self.value, Sequence) and not isinstance(self.value, str)) v = self._desc.joinChar.join(self._getEvalValue()) if withQuotes and v: return f'"{v}"' return v # String, File, single value Choice are based on strings and should includes quotes # to deal with spaces if withQuotes and isinstance(self._desc, (desc.StringParam, desc.File, desc.ChoiceParam)): return f'"{self._getEvalValue()}"' return str(self._getEvalValue()) def validateValue(self, value): """ Ensure value is compatible with the attribute description and convert value if needed. """ return self._desc.validateValue(value) def upgradeValue(self, exportedValue): """ Upgrade the attribute value within a compatibility node. """ self._setValue(exportedValue) def _isDefault(self): if self.keyable: return len(self._keyValues.pairs) == 0 else: return self._getValue() == self.getDefaultValue() def _isValid(self): """ Check attribute description validValue: - If it is a function, execute it and return the result - Otherwise, simply return true """ if callable(self._desc.validValue): try: return self._desc.validValue(self.node) except Exception as exc: if not self.node.isCompatibilityNode: logging.warning(f"Failed to evaluate 'isValid' (node lambda) for attribute '{self.fullName}': {exc}") return True return True def _is2dDisplayable(self) -> bool: """ Return True if the current attribute is considered as a displayable 2D file. """ if not self._desc.semantic: return False return next((imageSemantic for imageSemantic in Attribute.VALID_IMAGE_SEMANTICS if self._desc.semantic == imageSemantic), None) is not None def _is3dDisplayable(self) -> bool: """ Return True if the current attribute is considered as a displayable 3D file. """ if self._desc.semantic == "3d": return True # If the attribute is a File attribute, it is an instance of str and can be iterated over hasSupportedExt = isinstance(self.value, str) and any(ext in self.value for ext in Attribute.VALID_3D_EXTENSIONS) if hasSupportedExt: return True return False def _isTextDisplayable(self) -> bool: """ Return True if the current attribute is considered as a displayable text file. """ if self._desc.semantic == "textFile": return True # If the attribute is a File attribute, it is an instance of str and can be iterated over hasSupportedExt = isinstance(self.value, str) and any(self.value.endswith(ext) for ext in Attribute.VALID_TEXT_EXTENSIONS) if hasSupportedExt: return True return False def uid(self) -> str: """ Compute the UID for the attribute. """ if self.isOutput: if self._desc.isDynamicValue: # If the attribute is a dynamic output, the UID is derived from the node UID. # To guarantee that each output attribute receives a unique ID, we add the attribute # name to it. return hashValue((self.name, self.node._uid)) else: # Only dependent on the hash of its value without the cache folder. # "/" at the end of the link is stripped to prevent having different UIDs depending # on whether the invalidation value finishes with it or not strippedInvalidationValue = self._invalidationValue.rstrip("/") return hashValue(strippedInvalidationValue) if self.isLink: linkRootAttribute = self._getInputLink(recursive=True) return linkRootAttribute.uid() if self.keyable: return self._keyValues.uid() if isinstance(self._value, (list, tuple, set,)): # non-exclusive choice param # hash of sorted values hashed return hashValue([hashValue(v) for v in sorted(self._value)]) return hashValue(self._value) def updateInternals(self): """ Update attribute internal properties. """ # Emit if the enable status has changed self._setEnabled(self._getEnabled()) def _getEnabled(self) -> bool: if callable(self._desc.enabled): try: return self._desc.enabled(self.node) except Exception as exc: if not self.node.isCompatibilityNode: logging.warning(f"Failed to evaluate 'enabled' (node lambda) for attribute '{self.fullName}': {exc}") return True return self._desc.enabled def _setEnabled(self, v): if self._enabled == v: return self._enabled = v self.enabledChanged.emit() def _isLink(self) -> bool: """ Whether the attribute is a link to another attribute. """ return bool(self.node.graph and self.isInput and self.node.graph._edges and self in self.node.graph._edges.keys()) def _getInputLink(self, recursive=False) -> Attribute: """ Return the direct upstream connected attribute. :param recursive: recursive call, return the root attribute """ if not self.isLink: return None linkAttribute = self.node.graph.edge(self).src if recursive and linkAttribute.isLink: return linkAttribute._getInputLink(recursive) return linkAttribute def _getOutputLinks(self) -> list[Attribute]: """ Return the list of direct downstream connected attributes. """ # Safety check to avoid evaluation errors if not self.node.graph or not self.node.graph.edges: return [] return [edge.dst for edge in self.node.graph.edges.values() if edge.src == self] def _getAllInputLinks(self) -> list[Attribute]: """ Return the list of upstream connected attributes for the attribute or any of its elements. """ inputLink = self._getInputLink() if inputLink is None: return [] return [inputLink] def _getAllOutputLinks(self) -> list[Attribute]: """ Return the list of downstream connected attributes for the attribute or any of its elements. """ return self._getOutputLinks() def _hasAnyInputLinks(self) -> bool: """ Whether the attribute or any of its elements is a link to another attribute. """ # Safety check to avoid evaluation errors if not self.node.graph or not self.node.graph.edges: return False return next((edge for edge in self.node.graph.edges.values() if edge.dst == self), None) is not None def _hasAnyOutputLinks(self) -> bool: """ Whether the attribute or any of its elements is linked by another attribute. """ # Safety check to avoid evaluation errors if not self.node.graph or not self.node.graph.edges: return False return next((edge for edge in self.node.graph.edges.values() if edge.src == self), None) is not None def _getFlatStaticChildren(self) -> list[Attribute]: """ Return a list of all the attributes that refer to this Attribute as their parent through the "root" property. If no such attribute exist, return an empty list. The depth difference is not taken into account in the list, which is thus always flat. """ return [] def _validateIncomingConnection(self, connectingAttribute: Attribute) -> bool: """ Validation of the connection of "connectingAttribute" on this Attribute. This method can be overridden. Args: connectingAttribute: the Attribute attempting to connect to this one. Returns: True if the connection is valid, False otherwise. """ return self.baseType == connectingAttribute.baseType def connectTo(self, dstAttribute: Attribute) -> tuple[list[list[Attribute]], list[list[Attribute]]]: """ Connect this Attribute to "dstAttribute". Args: dstAttribute: the destination Attribute Returns: A tuple containing: - a list containing pairs of the source and destination Attributes (as lists) for every created edge - a list containing pairs of the source and destination Attributes (as lists) for every deleted edge """ if not (graph := self.node.graph): return [], [] deletedEdges = [] if isinstance(dstAttribute.root, Attribute): deletedEdges = dstAttribute.root.disconnectEdge() connectedEdge, deletedEdge = graph.addEdge(self, dstAttribute) if deletedEdge: deletedEdges.append(deletedEdge) return [connectedEdge], deletedEdges def disconnectEdge(self): """ Disconnect and remove the edge connected to this Attribute. Returns: A list of all the Edge objects that were deleted during the disconnection. """ if not (graph := self.node.graph): return [] deletedEdges = [] edge = graph.removeEdge(self) if edge: deletedEdges.append(edge) if isinstance(self.root, Attribute): deletedEdges += self.root.disconnectEdge() return deletedEdges # Slots @Slot() def _onKeyValuesChanged(self): """ For keyable attribute, when the list or pairs (key, value) is modified this method should be called. Emit Attribute.valueChanged and update node / graph like _setValue(). """ if self.isInput: self.requestGraphUpdate() self.requestNodeUpdate() self.valueChanged.emit() @Slot() def _onValueChanged(self): self.node._onAttributeChanged(self) @Slot(str, result=bool) def matchText(self, text: str) -> bool: return self.label.lower().find(text.lower()) > -1 @Slot(BaseObject, result=bool) def validateIncomingConnection(self, connectingAttribute: Attribute) -> bool: """ Return True if this Attribute can receive a connection from "connectingAttribute", False otherwise. """ return self._validateIncomingConnection(connectingAttribute) # Properties and signals # The node that contains this attribute. node = Property(BaseObject, lambda self: self._node(), constant=True) # The attribute that contains this attribute. root = Property(BaseObject, lambda self: self._root() if self._root else None, constant=True) # The attribute name following the path from the node to the attribute. fullName = Property(str, _getFullName, constant=True) # The attribute name following the path from the root attribute. rootName = Property(str, _getRootName, constant=True) # The description object of the attribute. desc = Property(desc.Attribute, lambda self: self._desc, constant=True) # The name of the attribute. name = Property(str, lambda self: self._desc._name, constant=True) # The human-readable label for the attribute. label = Property(str, lambda self: self._desc.label, constant=True) # The type of attribute as a string. type = Property(str, lambda self: self._desc.type, constant=True) # The type of the elements of the attribute as a string. baseType = Property(str, lambda self: self._desc.type, constant=True) # Whether the attribute is a node input attribute. isInput = Property(bool, lambda self: not self._isOutput, constant=True) # Whether the attribute is a node output attribute. isOutput = Property(bool, lambda self: self._isOutput, constant=True) # Whether the attribute is a read-only attribute. isReadOnly = Property(bool, lambda self: not self._isOutput and self.node.isCompatibilityNode, constant=True) # Whether changing this attribute invalidates cached results. invalidate = Property(bool, lambda self: self._invalidate, constant=True) # Whether this attribute is enabled. enabledChanged = Signal() enabled = Property(bool, _getEnabled, _setEnabled, notify=enabledChanged) # Depth level of this attribute. depth = Property(int, lambda self: self._depth, constant=True) # Whether the attribute is exposed (if it has a parent, the parent's value # takes precedence over the description's). exposed = Property(bool, lambda self: self._exposed, constant=True) # Attribute value properties and signals valueChanged = Signal() value = Property(Variant, _getValue, _setValue, notify=valueChanged) evalValue = Property(Variant, _getEvalValue, notify=valueChanged) # Whether the attribute can have a distinct value per key. keyable = Property(bool, lambda self: self._desc.keyable, constant=True) # The list of pairs (key, value) of the attribute. keyValues = Property(Variant, _getKeyValues, notify=valueChanged) # Whether the attribute value is the default value. isDefault = Property(bool, _isDefault, notify=valueChanged) # Whether the attribute value is valid. isValid = Property(bool, _isValid, notify=valueChanged) # Whether the attribute value is displayable in 2d. is2dDisplayable = Property(bool, _is2dDisplayable, constant=True) # Whether the attribute value is displayable in 3d. is3dDisplayable = Property(bool, _is3dDisplayable, constant=True) # Whether the attribute value is displayable as text. isTextDisplayable = Property(bool, _isTextDisplayable, constant=True) # Whether the attribute is a shape or a shape list, managed by the ShapeEditor and ShapeViewer. hasDisplayableShape = Property(bool, lambda self: False, constant=True) # Attribute link properties and signals inputLinksChanged = Signal() outputLinksChanged = Signal() # Whether the attribute is a link to another attribute. isLink = Property(bool, _isLink, notify=inputLinksChanged) # The upstream connected root attribute. inputRootLink = Property(Variant, lambda self: self._getInputLink(recursive=True), notify=inputLinksChanged) # The upstream connected attribute. inputLink = Property(BaseObject, _getInputLink, notify=inputLinksChanged) # The list of downstream connected attributes. outputLinks = Property(Variant, _getOutputLinks, notify=outputLinksChanged) # The list of upstream connected attributes for the attribute or any of its elements. allInputLinks = Property(Variant, _getAllInputLinks, notify=inputLinksChanged) # The list of downstream connected attributes for the attribute or any of its elements. allOutputLinks = Property(Variant, _getAllOutputLinks, notify=outputLinksChanged) # Whether the attribute or any of its elements is a link to another attribute. hasAnyInputLinks = Property(bool, _hasAnyInputLinks, notify=inputLinksChanged) # Whether the attribute or any of its elements is linked by another attribute. hasAnyOutputLinks = Property(bool, _hasAnyOutputLinks, notify=outputLinksChanged) # The list of attributes that refer to this one as their parent. flatStaticChildren = Property(Variant, _getFlatStaticChildren, constant=True) expressionApplied = Signal() def raiseIfLink(func): """ If Attribute instance is a link, raise a RuntimeError. """ def wrapper(attr, *args, **kwargs): if attr.isLink: raise RuntimeError("Can't modify connected Attribute") return func(attr, *args, **kwargs) return wrapper class PushButtonParam(Attribute): def __init__(self, node, attributeDesc: desc.PushButtonParam, isOutput: bool, root=None, parent=None): super().__init__(node, attributeDesc, isOutput, root, parent) @Slot() def clicked(self): self.node.onAttributeClicked(self) class ChoiceParam(Attribute): def __init__(self, node, attributeDesc: desc.ChoiceParam, isOutput: bool, root=None, parent=None): super().__init__(node, attributeDesc, isOutput, root, parent) self._values = None def __len__(self): return len(self.getValues()) def getValues(self): if (linkParam := self._getInputLink()) is not None: return linkParam.getValues() return self._values if self._values is not None else self._desc._values def setValues(self, values): if values == self._values: return self._values = values self.valuesChanged.emit() # Override def validateValue(self, value): if self._desc.exclusive: return self._conformValue(value) if isinstance(value, str): value = value.split(',') if not isinstance(value, Iterable): raise ValueError(f"Non exclusive ChoiceParam value should be iterable (param: {self.name}, " f"value: {value}, type: {type(value)})") return [self._conformValue(v) for v in value] def _conformValue(self, val): """ Conform 'val' to the correct type and check for its validity """ return self._desc.conformValue(val) # Override def _setValue(self, value): # Handle alternative serialization for ChoiceParam with overriden values. serializedValueWithValuesOverrides = isinstance(value, dict) if serializedValueWithValuesOverrides: super()._setValue(value[self._desc._OVERRIDE_SERIALIZATION_KEY_VALUE]) self.setValues(value[self._desc._OVERRIDE_SERIALIZATION_KEY_VALUES]) else: super()._setValue(value) # Override def getSerializedValue(self): useStandardSerialization = self.isLink or not self._desc._saveValuesOverride or \ self._values is None if useStandardSerialization: return super().getSerializedValue() return { self._desc._OVERRIDE_SERIALIZATION_KEY_VALUE: self._value, self._desc._OVERRIDE_SERIALIZATION_KEY_VALUES: self._values, } value = Property(Variant, Attribute._getValue, _setValue, notify=Attribute.valueChanged) valuesChanged = Signal() values = Property(Variant, getValues, setValues, notify=valuesChanged) class ListAttribute(Attribute): def __init__(self, node, attributeDesc: desc.ListAttribute, isOutput: bool, root=None, parent=None): super().__init__(node, attributeDesc, isOutput, root, parent) def __len__(self): if self.value is None: return 0 return len(self.value) def __iter__(self): return iter(self.value) def at(self, idx): """ Returns child attribute at index 'idx'. """ # Implement 'at' rather than '__getitem__' # since the later is called spuriously when object is used in QML return self.value.at(idx) def index(self, item): return self.value.indexOf(item) @raiseIfLink def append(self, value): self.extend([value]) @raiseIfLink def extend(self, values): self.insert(len(self), values) @raiseIfLink def insert(self, index, value): if self._value is None: self._value = ListModel(parent=self) values = value if isinstance(value, list) else [value] attrs = [attributeFactory(self._desc.elementDesc, v, self.isOutput, self.node, self) for v in values] self._value.insert(index, attrs) self.valueChanged.emit() self._applyExpr() self.requestGraphUpdate() @raiseIfLink def remove(self, index, count=1): if self._value is None: return if self.node.graph: from meshroom.core.graph import GraphModification with GraphModification(self.node.graph): # remove potential links for i in range(index, index + count): attr = self._value.at(i) if attr.isLink: # delete edge if the attribute is linked self.node.graph.removeEdge(attr) self._value.removeAt(index, count) self.requestGraphUpdate() self.valueChanged.emit() # Override def _initValue(self): self.resetToDefaultValue() # Override def _setValue(self, value): if self.node.graph: self.remove(0, len(self)) if self._handleLinkValue(value): return # New value else: # During initialization self._value may not be set if self._value is None: self._value = ListModel(parent=self) newValue = self._desc.validateValue(value) self.extend(newValue) self.requestGraphUpdate() # Override def _applyExpr(self): if self._linkExpression: super()._applyExpr() else: for value in self._value: value._applyExpr() # Override def resetToDefaultValue(self): self._value = ListModel(parent=self) self.valueChanged.emit() # Override def getDefaultValue(self) -> list: return [] # Override def getSerializedValue(self): if self.isLink: return self._getInputLink().asLinkExpr() return [attr.getSerializedValue() for attr in self._value] # Override def getPrimitiveValue(self, exportDefault=True): if exportDefault: return [attr.getPrimitiveValue(exportDefault=exportDefault) for attr in self._value] return [attr.getPrimitiveValue(exportDefault=exportDefault) for attr in self._value if not attr.isDefault] # Override def getValueStr(self, withQuotes=True) -> str: assert isinstance(self.value, ListModel) if self._desc.joinChar == ' ': return self._desc.joinChar.join([v.getValueStr(withQuotes=withQuotes) for v in self.value]) v = self._desc.joinChar.join([v.getValueStr(withQuotes=False) for v in self.value]) if withQuotes and v: return f'"{v}"' return v # Override def upgradeValue(self, exportedValues): if self._handleLinkValue(exportedValues): return if not isinstance(exportedValues, list): raise RuntimeError("ListAttribute.upgradeValue: the given value is of type " + str(type(exportedValues)) + " but a 'list' is expected.") attrs = [] for v in exportedValues: a = attributeFactory(self._desc.elementDesc, None, self.isOutput, self.node, self) a.upgradeValue(v) attrs.append(a) index = len(self._value) self._value.insert(index, attrs) self.valueChanged.emit() self._applyExpr() self.requestGraphUpdate() # Override def uid(self): if isinstance(self.value, ListModel): uids = [] for value in self.value: if value.invalidate: uids.append(value.uid()) return hashValue(uids) return super().uid() # Override def updateInternals(self): super().updateInternals() for attr in self._value: attr.updateInternals() # Override def _getAllInputLinks(self) -> list[Attribute]: """ Return the list of upstream connected attributes for the attribute or any of its elements. """ # Safety check to avoid evaluation errors if not self.node.graph or not self.node.graph.edges: return [] return [edge.src for edge in self.node.graph.edges.values() if edge.dst == self or edge.dst in self._value] # Override def _getAllOutputLinks(self) -> list[Attribute]: """ Return the list of downstream connected attributes for the attribute or any of its elements. """ # Safety check to avoid evaluation errors if not self.node.graph or not self.node.graph.edges: return [] return [edge.dst for edge in self.node.graph.edges.values() if edge.src == self or edge.src in self._value] # Override def _hasAnyInputLinks(self) -> bool: """ Whether the attribute or any of its elements is a link to another attribute. """ return super()._hasAnyInputLinks() or \ any(attribute.hasAnyInputLinks for attribute in self._value if hasattr(attribute, 'hasAnyInputLinks')) # Override def _hasAnyOutputLinks(self) -> bool: """ Whether the attribute or any of its elements is linked by another attribute. """ return super()._hasAnyOutputLinks() or \ any(attribute.hasAnyOutputLinks for attribute in self._value if hasattr(attribute, 'hasAnyOutputLinks')) # Override value property setter value = Property(Variant, Attribute._getValue, _setValue, notify=Attribute.valueChanged) isDefault = Property(bool, lambda self: len(self.value) == 0, notify=Attribute.valueChanged) baseType = Property(str, lambda self: self._desc.elementDesc.__class__.__name__, constant=True) # Override attribute link properties allInputLinks = Property(Variant, _getAllInputLinks, notify=Attribute.inputLinksChanged) allOutputLinks = Property(Variant, _getAllOutputLinks, notify=Attribute.outputLinksChanged) hasAnyInputLinks = Property(bool, _hasAnyInputLinks, notify=Attribute.inputLinksChanged) hasAnyOutputLinks = Property(bool, _hasAnyOutputLinks, notify=Attribute.outputLinksChanged) class GroupAttribute(Attribute): def __init__(self, node, attributeDesc: desc.GroupAttribute, isOutput: bool, root=None, parent=None): super().__init__(node, attributeDesc, isOutput, root, parent) def __getattr__(self, key): try: return super().__getattr__(key) except AttributeError: try: return self._value.get(key) except KeyError: raise AttributeError(key) # Override def _initValue(self): self._value = DictModel(keyAttrName='name', parent=self) subAttributes = [] for subAttrDesc in self._desc.items: childAttr = attributeFactory(subAttrDesc, None, self.isOutput, self.node, self) subAttributes.append(childAttr) childAttr.valueChanged.connect(self.valueChanged) self._value.reset(subAttributes) # Override def _getValue(self): return self._value # Override def _setValue(self, exportedValue): if self._handleLinkValue(exportedValue): return value = self.validateValue(exportedValue) if isinstance(value, dict): # set individual child attribute values for key, v in value.items(): self._value.get(key).value = v elif isinstance(value, (list, tuple)): if len(self._desc._items) != len(value): raise AttributeError(f"Incorrect number of values on GroupAttribute: {str(value)}") for attrDesc, v in zip(self._desc._items, value): self._value.get(attrDesc.name).value = v else: raise AttributeError(f"Failed to set on GroupAttribute: {str(value)}") # Override def _applyExpr(self): if self._linkExpression: super()._applyExpr() else: for value in self._value: value._applyExpr() # Override def resetToDefaultValue(self): for attrDesc in self._desc._items: self._value.get(attrDesc.name).resetToDefaultValue() # Override def getDefaultValue(self): return {key: attr.getDefaultValue() for key, attr in self._value.items()} # Override def getSerializedValue(self): if self.inputLink: return self.inputLink.asLinkExpr() return {key: attr.getSerializedValue() for key, attr in self._value.objects.items()} # Override def getPrimitiveValue(self, exportDefault=True): if exportDefault: return {name: attr.getPrimitiveValue(exportDefault=exportDefault) for name, attr in self._value.items()} return {name: attr.getPrimitiveValue(exportDefault=exportDefault) for name, attr in self._value.items() if not attr.isDefault} # Override def getValueStr(self, withQuotes=True): # add brackets if requested strBegin = '' strEnd = '' if self._desc.brackets is not None: if len(self._desc.brackets) == 2: strBegin = self._desc.brackets[0] strEnd = self._desc.brackets[1] else: raise AttributeError(f"Incorrect brackets on GroupAttribute: {self._desc.brackets}") # particular case when using space separator spaceSep = self._desc.joinChar == ' ' # sort values based on child attributes group description order sortedSubValues = [self._value.get(attr.name).getValueStr(withQuotes=spaceSep) for attr in self._desc.items] s = self._desc.joinChar.join(sortedSubValues) if withQuotes and not spaceSep: return f'"{strBegin}{s}{strEnd}"' return f'{strBegin}{s}{strEnd}' # Override def upgradeValue(self, exportedValue): if self._handleLinkValue(exportedValue): return value = self.validateValue(exportedValue) if isinstance(value, dict): # set individual child attribute values for key, v in value.items(): if key in self._value.keys(): self._value.get(key).upgradeValue(v) elif isinstance(value, (list, tuple)): if len(self._desc._items) != len(value): raise AttributeError(f"Incorrect number of values on GroupAttribute: {str(value)}") for attrDesc, v in zip(self._desc._items, value): self._value.get(attrDesc.name).upgradeValue(v) else: raise AttributeError(f"Failed to set on GroupAttribute: {str(value)}") # Override def uid(self): if self.isLink: return super().uid() uids = [] for _, v in self._value.items(): if v.enabled and v.invalidate: uids.append(v.uid()) return hashValue(uids) # Override def updateInternals(self): super().updateInternals() for attr in self._value: attr.updateInternals() # Override def _getFlatStaticChildren(self) -> list[Attribute]: attributes = [] # Iterate over the values and add the flat children of every child (if they exist) for attribute in self.value: attributes.append(attribute) attributes += attribute.flatStaticChildren return attributes # Override def _validateIncomingConnection(self, connectingAttribute: Attribute) -> bool: valid = super()._validateIncomingConnection(connectingAttribute) if not valid: # Attributes are not of the same base type return False return self._hasMatchingStructure(connectingAttribute) def _hasMatchingStructure(self, otherAttribute: Attribute) -> bool: """ Check whether this GroupAttribute and another Attribute have matching structures. Attributes have matching structures if they have the same number of children and if, at each position, both Attributes have the same base type. Args: otherAttribute: the other Attribute to compare structure with Returns: True if both Attributes have the same structure, False otherwise """ flatAttrs = self.flatStaticChildren otherFlatAttrs = otherAttribute.flatStaticChildren if len(flatAttrs) != len(otherFlatAttrs): return False for index, attribute in enumerate(flatAttrs): if attribute.baseType != otherFlatAttrs[index].baseType: return False return True # Override def connectTo(self, dstAttribute: GroupAttribute) -> tuple[list[list[Attribute]], list[list[Attribute]]]: """ Connect this GroupAttribute to "dstAttribute". The nested attributes in the group are automatically connected. Args: dstAttribute: the destination Attribute Returns: A tuple containing: - a list containing pairs of the source and destination Attributes (as lists) for every created edge - a list containing pairs of the source and destination Attributes (as lists) for every deleted edge """ nestedDstAttributes = list(dstAttribute.value) connectedEdges = [] deletedEdges = [] for index, nestedAttribute in enumerate(list(self.value)): # If the attributes are already connected, do not connect them again if not nestedDstAttributes[index] in nestedAttribute.outputLinks: connected, deleted = nestedAttribute.connectTo(nestedDstAttributes[index]) connectedEdges += connected deletedEdges += deleted connected, deleted = super().connectTo(dstAttribute) connectedEdges += connected deletedEdges += deleted return connectedEdges, deletedEdges @Slot(str, result=Attribute) def childAttribute(self, key: str) -> Attribute: """ Get child attribute by name or None if none was found. Args: key: the name of the child attribute Returns: Attribute: the child attribute or None """ try: return self._value.get(key) except KeyError: return None # Override @Slot(str, result=bool) def matchText(self, text: str) -> bool: return super().matchText(text) or any(c.matchText(text) for c in self._value) # Override value property value = Property(Variant, _getValue, _setValue, notify=Attribute.valueChanged) # Override flatStaticChildren property flatStaticChildren = Property(Variant, _getFlatStaticChildren, constant=True) isDefault = Property(bool, lambda self: all(v.isDefault for v in self.value), notify=Attribute.valueChanged) class GeometryAttribute(GroupAttribute): """ GroupAttribute subtype tailored for geometry-specific handling. """ def __init__(self, node, attributeDesc: desc.Geometry, isOutput: bool, root=None, parent=None): super().__init__(node, attributeDesc, isOutput, root, parent) # Override # Signal observationsChanged should be emitted. def _setValue(self, exportedValue): super()._setValue(exportedValue) self.observationsChanged.emit() # Override # Signal observationsChanged should be emitted. def resetToDefaultValue(self): super().resetToDefaultValue() self.observationsChanged.emit() # Override # Signal observationsChanged should be emitted. def upgradeValue(self, exportedValue): super().upgradeValue(exportedValue) self.observationsChanged.emit() # Override # Fix missing link expression serialization. # Should be remove if link expression serialization is added in GroupAttribute. def getSerializedValue(self): if self.isLink: return self._getInputLink().asLinkExpr() return super().getSerializedValue() def getValueAsDict(self) -> dict: """ Return the geometry attribute value as dict. For not keyable geometry, this is the same as getSerializedValue(). For keyable geometry, the dict is indexed by key. """ from collections import defaultdict outValue = defaultdict(dict) if not self.observationKeyable: return super().getSerializedValue() for attribute in self.value: if isinstance(attribute, GeometryAttribute): attributeDict = attribute.getValueAsDict() if attributeDict: for key, value in attributeDict.items(): outValue[key][attribute.name] = value else: for pair in attribute.keyValues.pairs: outValue[str(pair.key)][attribute.name] = pair.value return dict(outValue) def _hasKeyableChilds(self) -> bool: """ Whether all child attributes are keyable. """ return all((isinstance(attribute, GeometryAttribute) and attribute.observationKeyable) or attribute.keyable for attribute in self.value) def _getNbObservations(self) -> int: """ Return the geometry attribute number of observations. Note: Observation is a value defined across all child attributes for a specific key. """ if self.observationKeyable: firstAttribute = next(iter(self.value.values())) if isinstance(firstAttribute, GeometryAttribute): return firstAttribute.nbObservations return len(firstAttribute.keyValues.pairs) return 1 def _getObservationKeys(self) -> list: """ Return the geometry attribute list of observation keys. Note: Observation is a value defined across all child attributes for a specific key. """ if not self.observationKeyable: return [] firstAttribute = next(iter(self.value.values())) if isinstance(firstAttribute, GeometryAttribute): return firstAttribute.observationKeys return firstAttribute.keyValues.getKeys() @Slot(str, result=bool) def hasObservation(self, key: str) -> bool: """ Whether the geometry attribute has an observation for the given key. Note: Observation is a value defined across all child attributes for a specific key. """ if not self.observationKeyable: return True return all((isinstance(attribute, GeometryAttribute) and attribute.hasObservation(key)) or (not isinstance(attribute, GeometryAttribute) and attribute.keyValues.hasKey(key)) for attribute in self.value) @raiseIfLink def removeObservation(self, key: str): """ Remove the geometry attribute observation for the given key. Note: Observation is a value defined across all child attributes for a specific key. """ for attribute in self.value: if isinstance(attribute, GeometryAttribute): attribute.removeObservation(key) else: if attribute.keyable: attribute.keyValues.remove(key) else: attribute.resetToDefaultValue() self.observationsChanged.emit() @raiseIfLink def setObservation(self, key: str, observation: Variant): """ Set the geometry attribute observation for the given key with the given observation. Note: Observation is a value defined across all child attributes for a specific key. """ for attributeStr, value in observation.items(): attribute = self.childAttribute(attributeStr) if attribute is None: raise RuntimeError(f"Cannot set geometry observation for attribute {self._getFullName()} \ observation is incorrect.") if isinstance(attribute, GeometryAttribute): attribute.setObservation(key, value) else: if attribute.keyable: attribute.keyValues.add(key, value) else: attribute.value = value self.observationsChanged.emit() @Slot(str, result=Variant) def getObservation(self, key: str) -> Variant: """ Return the geometry attribute observation for the given key. Note: Observation is a value defined across all child attributes for a specific key. """ observation = {} for attribute in self.value: if isinstance(attribute, GeometryAttribute): geoObservation = attribute.getObservation(key) if geoObservation is None: return None else: observation[attribute.name] = geoObservation else: if attribute.keyable: if attribute.keyValues.hasKey(key): observation[attribute.name] = attribute.keyValues.getValueAtKeyOrDefault(key) else: return None else: observation[attribute.name] = attribute.value return observation # Properties and signals # Emitted when a geometry observation changed. observationsChanged = Signal() # Whether the geometry attribute childs are keyable. observationKeyable = Property(bool, _hasKeyableChilds, constant=True) # The list of geometry observation keys. observationKeys = Property(Variant, _getObservationKeys, notify=observationsChanged) # The number of geometry observation defined. nbObservations = Property(int, _getNbObservations, notify=observationsChanged) class ShapeAttribute(GroupAttribute): """ GroupAttribute subtype tailored for shape-specific handling. """ def __init__(self, node, attributeDesc: desc.Shape, isOutput: bool, root=None, parent=None): super().__init__(node, attributeDesc, isOutput, root, parent) self._visible = True # Override # Connect geometry attribute valueChanged to emit geometryChanged signal. def _initValue(self): super()._initValue() # Using Attribute.valueChanged for the userName, userColor, geometry properties results # in a segmentation fault. # As a workaround, we manually connect valueChanged to shapeChanged or geometryChanged. self.value.get("userName").valueChanged.connect(self._onShapeChanged) self.value.get("userColor").valueChanged.connect(self._onShapeChanged) self.geometry.valueChanged.connect(self._onGeometryChanged) # Override # Fix missing link expression serialization. # Should be remove if link expression serialization is added in GroupAttribute. def getSerializedValue(self): if self.isLink: return self._getInputLink().asLinkExpr() return super().getSerializedValue() def getShapeAsDict(self) -> dict: """ Return the shape attribute as dict with the shape file structure. """ outDict = { "name": self.userName if self.userName else self.rootName, "type": self.type, "properties": {"color": self.userColor} } if not self.geometry.observationKeyable: # Not keyable geometry, use properties. outDict.get("properties").update(self.geometry.getSerializedValue()) else: # Keyable geometry, use observations. outDict.update({"observations": self.geometry.getValueAsDict()}) return outDict def _getVisible(self) -> bool: """ Return whether the shape attribute is visible for display. """ return self._visible def _setVisible(self, visible: bool): """ Set the shape attribute visibility for display. """ self._visible = visible self.shapeChanged.emit() def _getUserName(self) -> str: """ Return the shape attribute user name for display. """ return self.value.get("userName").value def _getUserColor(self) -> str: """ Return the shape attribute user color for display. """ return self.value.get("userColor").value @Slot() def _onShapeChanged(self): """ Emit shapeChanged signal. Used when shape userName or userColor value changed. """ self.shapeChanged.emit() @Slot() def _onGeometryChanged(self): """ Emit geometryChanged signal. Used when geometry attribute value changed. """ self.geometryChanged.emit() # Properties and signals # Emitted when a shape related property changed (color, visibility). shapeChanged = Signal() # Emitted when a shape observation changed. geometryChanged = Signal() # Whether the shape is displayable. isVisible = Property(bool, _getVisible, _setVisible, notify=shapeChanged) # The shape user name for display. userName = Property(str, _getUserName, notify=shapeChanged) # The shape user color for display. userColor = Property(str, _getUserColor, notify=shapeChanged) # The shape geometry group attribute. geometry = Property(Variant, lambda self: self.value.get("geometry"), notify=geometryChanged) # Override hasDisplayableShape property. hasDisplayableShape = Property(bool, lambda self: True, constant=True) class ShapeListAttribute(ListAttribute): """ ListAttribute subtype tailored for shape-specific handling. """ def __init__(self, node, attributeDesc: desc.ShapeList, isOutput: bool, root=None, parent=None): super().__init__(node, attributeDesc, isOutput, root, parent) self._visible = True def getGeometriesAsDict(self): """ Return the geometries values of the children of the shape list attribute. """ return [shapeAttribute.geometry.getValueAsDict() for shapeAttribute in self.value] def getShapesAsDict(self): """ Return the children of the shape list attribute. """ return [shapeAttribute.getShapeAsDict() for shapeAttribute in self.value] def _getVisible(self) -> bool: """ Return whether the shape list is visible for display. """ if self.isLink: return self.inputLink.isVisible return self._visible def _setVisible(self, visible: bool): """ Set the shape visibility for display. """ if self.isLink: self.inputLink.isVisible = visible else: self._visible = visible for attribute in self.value: if isinstance(attribute, ShapeAttribute): attribute.isVisible = visible self.shapeListChanged.emit() # Properties and signals # Emitted when a shape list related property changed. shapeListChanged = Signal() # Whether the shape list is displayable. isVisible = Property(bool, _getVisible, _setVisible, notify=shapeListChanged) # Override hasDisplayableShape property. hasDisplayableShape = Property(bool, lambda self: True, constant=True) ================================================ FILE: meshroom/core/cgroup.py ================================================ #!/usr/bin/env python import os # Try to retrieve limits of memory for the current process' cgroup def getCgroupMemorySize(): # First of all, get pid of process pid = os.getpid() # Get cgroup associated with pid filename = f"/proc/{pid}/cgroup" cgroup = None try: with open(filename) as f: # cgroup file is a ':' separated table # lookup a line where the second field is "memory" lines = f.readlines() for line in lines: tokens = line.rstrip("\r\n").split(":") if len(tokens) < 3: continue if tokens[1] == "memory": cgroup = tokens[2] except OSError: pass if cgroup is None: return -1 size = -1 filename = f"/sys/fs/cgroup/memory/{cgroup}/memory.limit_in_bytes" try: with open(filename) as f: value = f.read().rstrip("\r\n") if value.isnumeric(): size = int(value) except OSError: pass return size def parseNumericList(numericListString): nList = [] for item in numericListString.split(','): if '-' in item: start, end = item.split('-') start = int(start) end = int(end) nList.extend(range(start, end + 1)) else: value = int(item) nList.append(value) return nList # Try to retrieve limits of cores for the current process' cgroup def getCgroupCpuCount(): # First of all, get pid of process pid = os.getpid() # Get cgroup associated with pid filename = f"/proc/{pid}/cgroup" cgroup = None try: with open(filename) as f: # cgroup file is a ':' separated table # lookup a line where the second field is "memory" lines = f.readlines() for line in lines: tokens = line.rstrip("\r\n").split(":") if len(tokens) < 3: continue if tokens[1] == "cpuset": cgroup = tokens[2] except OSError: pass if cgroup is None: return -1 size = -1 filename = f"/sys/fs/cgroup/cpuset/{cgroup}/cpuset.cpus" try: with open(filename) as f: value = f.read().rstrip("\r\n") nlist = parseNumericList(value) size = len(nlist) except OSError: pass return size ================================================ FILE: meshroom/core/desc/__init__.py ================================================ from .attribute import ( Attribute, BoolParam, ChoiceParam, ColorParam, File, FloatParam, GroupAttribute, IntParam, ListAttribute, PushButtonParam, StringParam, ValueTypeErrors, ) from .geometryAttribute import ( Geometry, Size2d, Vec2d, ) from .shapeAttribute import ( Shape, ShapeList, Point2d, Line2d, Rectangle, Circle ) from .computation import ( DynamicNodeSize, Level, MultiDynamicNodeSize, Parallelization, Range, StaticNodeSize, ) from .node import ( AVCommandLineNode, BaseNode, BackdropNode, CommandLineNode, InitNode, InputNode, InternalAttributesFactory, MrNodeType, Node, ) ================================================ FILE: meshroom/core/desc/attribute.py ================================================ import ast import os import re from collections.abc import Iterable from enum import auto, Enum from meshroom.common import BaseObject, JSValue, Property, Variant, VariantList, strtobool, deprecated # Pre-compile regexes for better performance on repeated calls _ACRONYM_RE = re.compile(r'([A-Z]+)([A-Z][a-z])') _CAMEL_CASE_RE = re.compile(r'([a-z\d])([A-Z])') _SPLIT_RE = re.compile(r'[_\s]+') def convertToLabel(name: str) -> str: """Convert a camelCase or snake_case attribute name into a human-readable label. Examples: >>> convertToLabel('camelCase') 'Camel Case' >>> convertToLabel('snake_case') 'Snake Case' >>> convertToLabel('myURLParser') 'My URL Parser' >>> convertToLabel('mixed_caseExample') 'Mixed Case Example' >>> convertToLabel('') '' """ if not name: return '' # Handle consecutive uppercase letters (e.g. 'URL', 'HTTP') name = _ACRONYM_RE.sub(r'\1 \2', name) # Insert space between camelCase boundaries name = _CAMEL_CASE_RE.sub(r'\1 \2', name) # Split on underscores or spaces words = _SPLIT_RE.split(name) # Preserve uppercase acronyms, capitalize others return ' '.join( word if word.isupper() else word.capitalize() for word in words if word ) class ValueTypeErrors(Enum): NONE = auto() # No error TYPE = auto() # Invalid type RANGE = auto() # Invalid range DYNAMIC_OUTPUT = auto() # Dynamic output not supported """ This object is used in group/commandLineGroup to check if the parameter has been set by the user (None is a valid parameter passed value) """ _setParamSentinel = object() class Attribute(BaseObject): """ """ def __init__(self, name, label, description, value, advanced, semantic, commandLineGroup, enabled, keyable=False, keyType=None, invalidate=True, uidIgnoreValue=None, validValue=True, errorMessage="", visible=True, exposed=False): super(Attribute, self).__init__() self._name = name self._label = convertToLabel(name) if label is None else label self._description = "" if description is None else description self._value = value self._keyable = keyable self._keyType = keyType self._commandLineGroup = commandLineGroup self._advanced = advanced self._enabled = enabled self._invalidate = invalidate self._semantic = semantic self._uidIgnoreValue = uidIgnoreValue self._validValue = validValue self._errorMessage = errorMessage self._visible = visible self._exposed = exposed self._isExpression = (isinstance(self._value, str) and "{" in self._value) \ or callable(self._value) self._isDynamicValue = (self._value is None) self._valueType = None def getInstanceType(self): """ Return the correct Attribute instance corresponding to the description. """ # Import within the method to prevent cyclic dependencies from meshroom.core.attribute import Attribute return Attribute def validateValue(self, value): """ Return validated/conformed 'value'. Need to be implemented in derived classes. Raises: ValueError: if value does not have the proper type """ raise NotImplementedError("Attribute.validateValue is an abstract function that should be " "implemented in the derived class.") def validateKeyValues(self, keyValues): """ Return validated/conformed 'keyValues'. Raises: ValueError: if a value does not have the proper type """ return isinstance(keyValues, dict) and \ all(isinstance(k, str) and self.validateValue(v) for k,v in keyValues.items()) def checkValueTypes(self): """ Returns the attribute's name if the default value's type is invalid or if the range's type (when available) is invalid, empty string otherwise. Returns: string: the attribute's name if the default value's or range's type is invalid, empty string otherwise """ raise NotImplementedError("Attribute.checkValueTypes is an abstract function that should be implemented in the " "derived class.") def matchDescription(self, value, strict=True): """ Returns whether the value perfectly match attribute's description. Args: value: the value strict: strict test for the match (for instance, regarding a group with some parameter changes) """ try: if self._keyable: self.validateKeyValues(value) else: self.validateValue(value) except ValueError: return False return True name = Property(str, lambda self: self._name, constant=True) label = Property(str, lambda self: self._label, constant=True) description = Property(str, lambda self: self._description, constant=True) value = Property(Variant, lambda self: self._value, constant=True) # isExpression: # The default value of the attribute's descriptor is a static string expression that should be evaluated at runtime. # This property only makes sense for output attributes. isExpression = Property(bool, lambda self: self._isExpression, constant=True) # isDynamicValue # The default value of the attribute's descriptor is None, so it is not an input value, # but an output value that is computed during the Node's process execution. isDynamicValue = Property(bool, lambda self: self._isDynamicValue, constant=True) # keyable: # Whether the attribute can have a distinct value per key. # By default, atribute value is not keyable. keyable = Property(bool, lambda self: self._keyable, constant=True) # keyType: # The type of key corresponding to the attribute value. # This property only makes sense for keyable attributes. keyType = Property(str, lambda self: self._keyType, constant=True) commandLineGroup = Property(str, lambda self: self._commandLineGroup, constant=True) advanced = Property(bool, lambda self: self._advanced, constant=True) enabled = Property(Variant, lambda self: self._enabled, constant=True) invalidate = Property(Variant, lambda self: self._invalidate, constant=True) semantic = Property(str, lambda self: self._semantic, constant=True) uidIgnoreValue = Property(Variant, lambda self: self._uidIgnoreValue, constant=True) validValue = Property(Variant, lambda self: self._validValue, constant=True) errorMessage = Property(str, lambda self: self._errorMessage, constant=True) # visible: # The attribute is not displayed in the Graph Editor if False but still visible in the Node Editor. # This property is useful to hide some attributes that are not relevant for the user. visible = Property(bool, lambda self: self._visible, constant=True) # exposed: # The attribute is exposed in the upper part of the node in the Graph Editor. # By default, all file attributes are exposed. exposed = Property(bool, lambda self: self._exposed, constant=True) type = Property(str, lambda self: self.__class__.__name__, constant=True) # instanceType # Attribute instance corresponding to the description instanceType = Property(Variant, lambda self: self.getInstanceType(), constant=True) class ListAttribute(Attribute): """ A list of Attributes """ @deprecated.depreciateParam("group", "Param 'group' on {name} should not be used anymore. Please use 'commandLineGroup' instead") def __init__(self, elementDesc, name, label=None, description=None, group="allParams", commandLineGroup=_setParamSentinel, advanced=False, semantic="", enabled=True, joinChar=" ", visible=True, exposed=False): """ :param elementDesc: the Attribute description of elements to store in that list """ self._elementDesc = elementDesc self._joinChar = joinChar commandLineGroup = commandLineGroup if commandLineGroup is not _setParamSentinel else group super(ListAttribute, self).__init__(name=name, label=label, description=description, value=[], invalidate=False, commandLineGroup=commandLineGroup, advanced=advanced, semantic=semantic, enabled=enabled, visible=visible, exposed=exposed) def getInstanceType(self): # Import within the method to prevent cyclic dependencies from meshroom.core.attribute import ListAttribute return ListAttribute def validateValue(self, value): if value is None: return value if JSValue is not None and isinstance(value, JSValue): # Note: we could use isArray(), property("length").toInt() to retrieve all values raise ValueError("ListAttribute.validateValue: cannot recognize QJSValue. " "Please, use JSON.stringify(value) in QML.") if isinstance(value, str): # Alternative solution to set values from QML is to convert values to JSON string # In this case, it works with all data types value = ast.literal_eval(value) if not isinstance(value, (list, tuple)): raise ValueError(f"ListAttribute only supports list/tuple input values " f"(param: {self.name}, value: {value}, type: {type(value)})") return value def checkValueTypes(self): return self.elementDesc.checkValueTypes() def matchDescription(self, value, strict=True): """ Check that 'value' content matches ListAttribute's element description. """ if not super(ListAttribute, self).matchDescription(value, strict): return False # list must be homogeneous: only test first element if value: return self._elementDesc.matchDescription(value[0], strict) return True elementDesc = Property(Attribute, lambda self: self._elementDesc, constant=True) invalidate = Property(Variant, lambda self: self.elementDesc.invalidate, constant=True) joinChar = Property(str, lambda self: self._joinChar, constant=True) class GroupAttribute(Attribute): """ A macro Attribute composed of several Attributes """ @deprecated.depreciateParam("group", "Param 'group' on {name} should not be used anymore. Please use 'commandLineGroup' instead") def __init__(self, items, name, label=None, description=None, group="allParams", commandLineGroup=_setParamSentinel, advanced=False, semantic="", enabled=True, joinChar=" ", brackets=None, visible=True, exposed=False): """ :param items: the description of the Attributes composing this group """ self._items = items self._joinChar = joinChar self._brackets = brackets commandLineGroup = commandLineGroup if commandLineGroup is not _setParamSentinel else group super(GroupAttribute, self).__init__(name=name, label=label, description=description, value={}, commandLineGroup=commandLineGroup, advanced=advanced, invalidate=False, semantic=semantic, enabled=enabled, visible=visible, exposed=exposed) def getInstanceType(self): # Import within the method to prevent cyclic dependencies from meshroom.core.attribute import GroupAttribute return GroupAttribute def validateValue(self, value): """ Ensure value is compatible with the group description and convert value if needed. """ if value is None: return value if JSValue is not None and isinstance(value, JSValue): # Note: we could use isArray(), property("length").toInt() to retrieve all values raise ValueError("GroupAttribute.validateValue: cannot recognize QJSValue. " "Please, use JSON.stringify(value) in QML.") if isinstance(value, str): # Alternative solution to set values from QML is to convert values to JSON string # In this case, it works with all data types value = ast.literal_eval(value) if isinstance(value, dict): # invalidKeys = set(value.keys()).difference([attr.name for attr in self._items]) # if invalidKeys: # raise ValueError(f"Value contains key that does not match group description: " # f"{invalidKeys}") if self._items and value.keys(): commonKeys = set(value.keys()).intersection([attr.name for attr in self._items]) if not commonKeys: raise ValueError(f"Value contains no key that matches with the group " f"description (name={self.name}, values={value.keys()}, " f"desc={[attr.name for attr in self._items]})") elif isinstance(value, (list, tuple, set)): if len(value) != len(self._items): raise ValueError(f"Value contains incoherent number of values: " f"desc size: {len(self._items)}, value size: {len(value)}") else: raise ValueError(f"GroupAttribute only supports dict/list/tuple input values " f"(param: {self.name}, value: {value}, type: {type(value)})") return value def checkValueTypes(self): """ Check the default value's and range's (if available) type of every attribute contained in the group (including nested attributes). Returns an empty string if all the attributes' types are valid, or concatenates the names of the attributes in the group with invalid types. """ invalidParams = [] for attr in self.items: name, error = attr.checkValueTypes() if name: invalidParams.append(name) if invalidParams: # In group "group", if parameters "x" and "y" (with "y" in nested group "subgroup") are invalid, the # returned string will be: "group:x, group:subgroup:y" return self.name + ":" + str(", " + self.name + ":").join(invalidParams), error return "", ValueTypeErrors.NONE def matchDescription(self, value, strict=True): """ Check that 'value' contains the exact same set of keys as GroupAttribute's group description and that every child value match corresponding child attribute description. Args: value: the value strict: strict test for the match (for instance, regarding a group with some parameter changes) """ if not super(GroupAttribute, self).matchDescription(value): return False attrMap = {attr.name: attr for attr in self._items} matchCount = 0 for k, v in value.items(): # each child value must match corresponding child attribute description if k in attrMap and attrMap[k].matchDescription(v, strict): matchCount += 1 if strict: return matchCount == len(value.items()) == len(self._items) return matchCount > 0 def retrieveChildrenInvalidations(self): allInvalidations = [] for desc in self._items: allInvalidations.append(desc.invalidate) return allInvalidations items = Property(Variant, lambda self: self._items, constant=True) invalidate = Property(Variant, retrieveChildrenInvalidations, constant=True) joinChar = Property(str, lambda self: self._joinChar, constant=True) brackets = Property(str, lambda self: self._brackets, constant=True) class Param(Attribute): """ """ def __init__(self, name, label, description, value, commandLineGroup, advanced, semantic, enabled, keyable=False, keyType=None, invalidate=True, uidIgnoreValue=None, validValue=True, errorMessage="", visible=True, exposed=False): super(Param, self).__init__(name=name, label=label, description=description, value=value, keyable=keyable, keyType=keyType, commandLineGroup=commandLineGroup, advanced=advanced, enabled=enabled, invalidate=invalidate, semantic=semantic, uidIgnoreValue=uidIgnoreValue, validValue=validValue, errorMessage=errorMessage, visible=visible, exposed=exposed) class File(Attribute): """ """ @deprecated.depreciateParam("group", "Param 'group' on {name} should not be used anymore. Please use 'commandLineGroup' instead") def __init__(self, name, label=None, description=None, value=None, group="allParams", commandLineGroup=_setParamSentinel, advanced=False, invalidate=True, semantic="", enabled=True, visible=True, exposed=True): commandLineGroup = commandLineGroup if commandLineGroup is not _setParamSentinel else group super(File, self).__init__(name=name, label=label, description=description, value=value, commandLineGroup=commandLineGroup, advanced=advanced, enabled=enabled, invalidate=invalidate, semantic=semantic, visible=visible, exposed=exposed) self._valueType = str def validateValue(self, value): if value is None: return value if not isinstance(value, str): raise ValueError(f"File only supports string input (param: {self.name}, value: " f"{value}, type: {type(value)})") return os.path.normpath(value).replace("\\", "/") if value else "" def checkValueTypes(self): if self.value is None: return "", ValueTypeErrors.NONE # Some File values are functions generating a string: check whether the value is a string or if it # is a function (but there is no way to check that the function's output is indeed a string) if not isinstance(self.value, str) and not callable(self.value): return self.name, ValueTypeErrors.TYPE return "", ValueTypeErrors.NONE class BoolParam(Param): """ """ @deprecated.depreciateParam("group", "Param 'group' on {name} should not be used anymore. Please use 'commandLineGroup' instead") def __init__(self, name, label=None, description=None, value=None, keyable=False, keyType=None, group="allParams", commandLineGroup=_setParamSentinel, advanced=False, enabled=True, invalidate=True, semantic="", visible=True, exposed=False): commandLineGroup = commandLineGroup if commandLineGroup is not _setParamSentinel else group super(BoolParam, self).__init__(name=name, label=label, description=description, value=value, keyable=keyable, keyType=keyType, commandLineGroup=commandLineGroup, advanced=advanced, enabled=enabled, invalidate=invalidate, semantic=semantic, visible=visible, exposed=exposed) self._valueType = bool def validateValue(self, value): if value is None: return value try: if isinstance(value, str): return bool(strtobool(value)) return bool(value) except Exception: raise ValueError(f"BoolParam only supports bool value (param: {self.name}, " f"value: {value}, type: {type(value)})") def checkValueTypes(self): if self.value is None: return "", ValueTypeErrors.NONE if not isinstance(self.value, bool): return self.name, ValueTypeErrors.TYPE return "", ValueTypeErrors.NONE class IntParam(Param): """ """ @deprecated.depreciateParam("group", "Param 'group' on {name} should not be used anymore. Please use 'commandLineGroup' instead") def __init__(self, name, label=None, description=None, value=None, range=None, keyable=False, keyType=None, group="allParams", commandLineGroup=_setParamSentinel, advanced=False, enabled=True, invalidate=True, semantic="", validValue=True, errorMessage="", visible=True, exposed=False): self._range = range commandLineGroup = commandLineGroup if commandLineGroup is not _setParamSentinel else group super(IntParam, self).__init__(name=name, label=label, description=description, value=value, keyable=keyable, keyType=keyType, commandLineGroup=commandLineGroup, advanced=advanced, enabled=enabled, invalidate=invalidate, semantic=semantic, validValue=validValue, errorMessage=errorMessage, visible=visible, exposed=exposed) self._valueType = int def validateValue(self, value): if value is None: return value # Handle unsigned int values that are translated to int by shiboken and may overflow try: return int(value) except Exception: raise ValueError(f"IntParam only supports int value (param: {self.name}, value: " f"{value}, type: {type(value)})") def checkValueTypes(self): if self.value is None: return "", ValueTypeErrors.NONE if not isinstance(self.value, int): return self.name, ValueTypeErrors.TYPE if (self.range and not all([isinstance(r, int) for r in self.range])): return self.name, ValueTypeErrors.RANGE return "", ValueTypeErrors.NONE range = Property(VariantList, lambda self: self._range, constant=True) class FloatParam(Param): """ """ @deprecated.depreciateParam("group", "Param 'group' on {name} should not be used anymore. Please use 'commandLineGroup' instead") def __init__(self, name, label=None, description=None, value=None, range=None, keyable=False, keyType=None, group="allParams", commandLineGroup=_setParamSentinel, advanced=False, enabled=True, invalidate=True, semantic="", validValue=True, errorMessage="", visible=True, exposed=False): self._range = range commandLineGroup = commandLineGroup if commandLineGroup is not _setParamSentinel else group super(FloatParam, self).__init__(name=name, label=label, description=description, value=value, keyable=keyable, keyType=keyType, commandLineGroup=commandLineGroup, advanced=advanced, enabled=enabled, invalidate=invalidate, semantic=semantic, validValue=validValue, errorMessage=errorMessage, visible=visible, exposed=exposed) self._valueType = float def validateValue(self, value): if value is None: return value try: return float(value) except Exception: raise ValueError(f"FloatParam only supports float value (param: {self.name}, value: " f"{value}, type:{type(value)})") def checkValueTypes(self): if self.value is None: return "", ValueTypeErrors.NONE if not isinstance(self.value, float): return self.name, ValueTypeErrors.TYPE if (self.range and not all([isinstance(r, float) for r in self.range])): return self.name, ValueTypeErrors.RANGE return "", ValueTypeErrors.NONE range = Property(VariantList, lambda self: self._range, constant=True) class PushButtonParam(Param): """ """ @deprecated.depreciateParam("group", "Param 'group' on {name} should not be used anymore. Please use 'commandLineGroup' instead") def __init__(self, name, label=None, description=None, group="allParams", commandLineGroup=_setParamSentinel, advanced=False, enabled=True, invalidate=True, semantic="", visible=True, exposed=False): commandLineGroup = commandLineGroup if commandLineGroup is not _setParamSentinel else group super(PushButtonParam, self).__init__(name=name, label=label, description=description, value=None, commandLineGroup=commandLineGroup, advanced=advanced, enabled=enabled, invalidate=invalidate, semantic=semantic, visible=visible, exposed=exposed) self._valueType = None def getInstanceType(self): # Import within the method to prevent cyclic dependencies from meshroom.core.attribute import PushButtonParam return PushButtonParam def validateValue(self, value): return value def checkValueTypes(self): return "", ValueTypeErrors.NONE class ChoiceParam(Param): """ ChoiceParam is an Attribute that allows to choose a value among a list of possible values. When using `exclusive=True`, the value is a single element of the list of possible values. When using `exclusive=False`, the value is a list of elements of the list of possible values. Despite this being the standard behavior, ChoiceParam also supports custom value: it is possible to set any value, even outside list of possible values. The list of possible values on a ChoiceParam instance can be overriden at runtime. If those changes needs to be persisted, `saveValuesOverride` should be set to True. """ # Keys for values override serialization schema (saveValuesOverride=True). _OVERRIDE_SERIALIZATION_KEY_VALUE = "__ChoiceParam_value__" _OVERRIDE_SERIALIZATION_KEY_VALUES = "__ChoiceParam_values__" @deprecated.depreciateParam("group", "Param 'group' on {name} should not be used anymore. Please use 'commandLineGroup' instead") def __init__(self, name: str, label=None, description=None, value=None, values=None, exclusive=True, saveValuesOverride=False, group="allParams", commandLineGroup=_setParamSentinel, joinChar=" ", advanced=False, enabled=True, invalidate=True, semantic="", validValue=True, errorMessage="", visible=True, exposed=False): commandLineGroup = commandLineGroup if commandLineGroup is not _setParamSentinel else group super(ChoiceParam, self).__init__(name=name, label=label, description=description, value=value, commandLineGroup=commandLineGroup, advanced=advanced, enabled=enabled, invalidate=invalidate, semantic=semantic, validValue=validValue, errorMessage=errorMessage, visible=visible, exposed=exposed) self._values = values if values is not None else [] self._saveValuesOverride = saveValuesOverride self._exclusive = exclusive self._joinChar = joinChar if self._values: # Look at the type of the first element of the possible values self._valueType = type(self._values[0]) elif not exclusive and self._value is not None: # Possible values may be defined later, so use the value to define the type. # if non exclusive, it is a list self._valueType = type(self._value[0]) else: self._valueType = type(self._value) def getInstanceType(self): # Import within the method to prevent cyclic dependencies from meshroom.core.attribute import ChoiceParam return ChoiceParam def conformValue(self, value): """ Conform 'value' to the correct type and check for its validity """ # We do not check that the value is in the list of values. # This allows to have a value that is not in the list of possible values. return self._valueType(value) def validateValue(self, value): if value is None: return value serializedWithValuesOverride = isinstance(value, dict) if serializedWithValuesOverride: value = value[ChoiceParam._OVERRIDE_SERIALIZATION_KEY_VALUE] if self.exclusive: return self.conformValue(value) if isinstance(value, str): value = value.split(',') if not isinstance(value, Iterable): raise ValueError(f"Non-exclusive ChoiceParam value should be iterable (param: " f"{self.name}, value: {value}, type: {type(value)}).") return [self.conformValue(v) for v in value] def checkValueTypes(self): # Check that the values have been provided as a list if not isinstance(self._values, list): return self.name, ValueTypeErrors.TYPE # None value is valid (dynamic default) if self._value is None: return "", ValueTypeErrors.NONE # If the choices are not exclusive, check that 'value' is a list, and check that it does not contain values that # are not available elif not self.exclusive and (not isinstance(self._value, list) or not all(val in self._values for val in self._value)): return self.name, ValueTypeErrors.RANGE # If the choices are exclusive, the value should NOT be a list but it can contain any value that is not in the # list of possible ones elif self.exclusive and isinstance(self._value, list): return self.name, ValueTypeErrors.TYPE return "", ValueTypeErrors.NONE values = Property(VariantList, lambda self: self._values, constant=True) exclusive = Property(bool, lambda self: self._exclusive, constant=True) joinChar = Property(str, lambda self: self._joinChar, constant=True) class StringParam(Param): """ """ @deprecated.depreciateParam("group", "Param 'group' on {name} should not be used anymore. Please use 'commandLineGroup' instead") def __init__(self, name, label=None, description=None, value=None, group="allParams", commandLineGroup=_setParamSentinel, advanced=False, enabled=True, invalidate=True, semantic="", uidIgnoreValue=None, validValue=True, errorMessage="", visible=True, exposed=False): commandLineGroup = commandLineGroup if commandLineGroup is not _setParamSentinel else group super(StringParam, self).__init__(name=name, label=label, description=description, value=value, commandLineGroup=commandLineGroup, advanced=advanced, enabled=enabled, invalidate=invalidate, semantic=semantic, uidIgnoreValue=uidIgnoreValue, validValue=validValue, errorMessage=errorMessage, visible=visible, exposed=exposed) self._valueType = str def validateValue(self, value): if value is None: return value if not isinstance(value, str): raise ValueError(f"StringParam value should be a string (param: " f"{self.name}, value: {value}, type: {type(value)})") return value def checkValueTypes(self): if self.value is None: return "", ValueTypeErrors.NONE if not isinstance(self.value, str): return self.name, ValueTypeErrors.TYPE return "", ValueTypeErrors.NONE class ColorParam(Param): """ """ @deprecated.depreciateParam("group", "Param 'group' on {name} should not be used anymore. Please use 'commandLineGroup' instead") def __init__(self, name, label=None, description=None, value=None, group="allParams", commandLineGroup=_setParamSentinel, advanced=False, enabled=True, invalidate=True, semantic="", visible=True, exposed=False): commandLineGroup = commandLineGroup if commandLineGroup is not _setParamSentinel else group super(ColorParam, self).__init__(name=name, label=label, description=description, value=value, commandLineGroup=commandLineGroup, advanced=advanced, enabled=enabled, invalidate=invalidate, semantic=semantic, visible=visible, exposed=exposed) self._valueType = str def validateValue(self, value): if value is None: return value if not isinstance(value, str) or len(value.split(" ")) > 1: raise ValueError(f"ColorParam value should be a string containing either an SVG name " f"or an hexadecimal color code (param: {self.name}, value: {value}, " f"type: {type(value)})") return value def checkValueTypes(self): if self.value is None: return "", ValueTypeErrors.NONE if not isinstance(self.value, str): return self.name, ValueTypeErrors.TYPE return "", ValueTypeErrors.NONE ================================================ FILE: meshroom/core/desc/computation.py ================================================ import math from enum import IntEnum from .attribute import ListAttribute, IntParam class Level(IntEnum): SCRIPT=-1 NONE = 0 NORMAL = 1 INTENSIVE = 2 EXTREME = 3 class Range: def __init__(self, iteration=0, blockSize=0, fullSize=0, nbBlocks=0): self.iteration = iteration self.blockSize = blockSize self.fullSize = fullSize self.nbBlocks = nbBlocks @property def start(self): return self.iteration * self.blockSize @property def effectiveBlockSize(self): remaining = (self.fullSize - self.start) + 1 return self.blockSize if remaining >= self.blockSize else remaining @property def end(self): return self.start + self.effectiveBlockSize @property def last(self): return self.end - 1 def toDict(self): return { "rangeIteration": self.iteration, "rangeStart": self.start, "rangeEnd": self.end, "rangeLast": self.last, "rangeBlockSize": self.blockSize, "rangeEffectiveBlockSize": self.effectiveBlockSize, "rangeFullSize": self.fullSize, "rangeBlocksCount": self.nbBlocks } def __repr__(self): return f"" class Parallelization: def __init__(self, staticNbBlocks=0, blockSize=0): self.staticNbBlocks = staticNbBlocks self.blockSize = blockSize def getSizes(self, node): """ Args: node: Returns: (blockSize, fullSize, nbBlocks) """ size = node.size if self.blockSize: nbBlocks = int(math.ceil(float(size) / float(self.blockSize))) return self.blockSize, size, nbBlocks if self.staticNbBlocks: return 1, self.staticNbBlocks, self.staticNbBlocks return None def getRange(self, node, iteration): blockSize, fullSize, nbBlocks = self.getSizes(node) return Range(iteration=iteration, blockSize=blockSize, fullSize=fullSize, nbBlocks=nbBlocks) def getRanges(self, node): blockSize, fullSize, nbBlocks = self.getSizes(node) ranges = [] for i in range(nbBlocks): ranges.append(Range(iteration=i, blockSize=blockSize, fullSize=fullSize, nbBlocks=nbBlocks)) return ranges class DynamicNodeSize(object): """ DynamicNodeSize expresses a dependency to an input attribute to define the size of a Node in terms of individual tasks for parallelization. If the attribute is a link to another node, Node's size will be the same as this connected node. If the attribute is a ListAttribute, Node's size will be the size of this list. """ def __init__(self, param): self._param = param def __call__(self, node): param = node.attribute(self._param) # Link: use linked node's size if param.isLink: return param.inputLink.node.size # ListAttribute: use list size if isinstance(param.desc, ListAttribute): return len(param) if isinstance(param.desc, IntParam): return param.value return 1 class MultiDynamicNodeSize(object): """ MultiDynamicNodeSize expresses dependencies to multiple input attributes to define the size of a node in terms of individual tasks for parallelization. Works as DynamicNodeSize and sum the sizes of each dependency. """ def __init__(self, params): """ Args: params (list): list of input attributes names """ assert isinstance(params, (list, tuple)) self._params = params def __call__(self, node): size = 0 for param in self._params: param = node.attribute(param) if param.isLink: size += param.inputLink.node.size elif isinstance(param.desc, ListAttribute): size += len(param) else: size += 1 return size class StaticNodeSize(object): """ StaticNodeSize expresses a static Node size in terms of individual tasks for parallelization. """ def __init__(self, size): self._size = size def __call__(self, node): return self._size ================================================ FILE: meshroom/core/desc/geometryAttribute.py ================================================ from meshroom.core.desc import GroupAttribute, FloatParam class Geometry(GroupAttribute): """ Base attribute for all Geometry attribute. Countains several attributes (inherit from GroupAttribute). """ def __init__(self, items, name, label=None, description=None, commandLineGroup="allParams", advanced=False, semantic="", enabled=True, visible=True, exposed=False): # GroupAttribute constructor super(Geometry, self).__init__(items=items, name=name, label=label, description=description, commandLineGroup=commandLineGroup, advanced=advanced, semantic=semantic, enabled=enabled, visible=visible, exposed=exposed) def getInstanceType(self): """ Return the correct Attribute instance corresponding to the description. """ # Import within the method to prevent cyclic dependencies from meshroom.core.attribute import GeometryAttribute return GeometryAttribute class Size2d(Geometry): """ Size2d is a Geometry attribute that allows to specify a 2d size. """ def __init__(self, name, label=None, description=None, width=None, height=None, widthRange=None, heightRange=None, keyable=False, keyType=None, commandLineGroup="allParams", advanced=False, semantic="", enabled=True, visible=True, exposed=False): # Geometry group desciption items = [ FloatParam(name="width", label="Width", description="Width size.", value=width, range=widthRange, keyable=keyable, keyType=keyType, commandLineGroup=commandLineGroup, advanced=advanced, enabled=enabled, visible=visible, exposed=exposed), FloatParam(name="height", label="Height", description="Height size.", value=height, range=heightRange, keyable=keyable, keyType=keyType, commandLineGroup=commandLineGroup, advanced=advanced, enabled=enabled, visible=visible, exposed=exposed) ] # GeometryAttribute constructor super(Size2d, self).__init__(items, name, label, description, commandLineGroup=None, advanced=advanced, semantic=semantic, enabled=enabled, visible=visible, exposed=exposed) class Vec2d(Geometry): """ Vec2d is a Geometry attribute that allows to specify a 2d vector. """ def __init__(self, name, label=None, description=None, x=None, y=None, xRange=None, yRange=None, keyable=False, keyType=None, commandLineGroup="allParams", advanced=False, semantic="", enabled=True, visible=True, exposed=False): # Geometry group desciption items = [ FloatParam(name="x", label="X", description="X coordinate.", value=x, range=xRange, keyable=keyable, keyType=keyType, commandLineGroup=commandLineGroup, advanced=advanced, enabled=enabled, visible=visible, exposed=exposed), FloatParam(name="y", label="Y", description="Y coordinate.", value=y, range=yRange, keyable=keyable, keyType=keyType, commandLineGroup=commandLineGroup, advanced=advanced, enabled=enabled, visible=visible, exposed=exposed) ] # GeometryAttribute constructor super(Vec2d, self).__init__(items, name, label, description, commandLineGroup=None, advanced=advanced, semantic=semantic, enabled=enabled, visible=visible, exposed=exposed) ================================================ FILE: meshroom/core/desc/node.py ================================================ import enum from inspect import getfile, getattr_static from pathlib import Path import logging import shlex import shutil import sys import signal import subprocess import psutil import meshroom from meshroom.core import cgroup from meshroom.core.utils import VERBOSE_LEVEL from .computation import Level, StaticNodeSize from .attribute import Attribute, ChoiceParam, ColorParam, IntParam, StringParam _MESHROOM_ROOT = Path(meshroom.__file__).parent.parent.as_posix() _MESHROOM_COMPUTE = (Path(_MESHROOM_ROOT) / "bin" / "meshroom_compute").as_posix() _MESHROOM_COMPUTE_DEPS = ["psutil"] # Handle cleanup class ExitCleanup: """ Make sure we kill child subprocesses when the main process exits receive SIGTERM. """ def __init__(self): self._subprocesses = [] signal.signal(signal.SIGTERM, self.exit) def addSubprocess(self, process): logging.debug(f"[ExitCleanup] Register subprocess {process}") self._subprocesses.append(process) def exit(self, signum, frame): for proc in self._subprocesses: logging.debug(f"[ExitCleanup] Kill subprocess {proc}") try: if proc.is_running(): proc.terminate() proc.wait(timeout=5) except subprocess.TimeoutExpired: proc.kill() sys.exit(0) exitCleanup = ExitCleanup() class MrNodeType(enum.Enum): NONE = enum.auto() BASENODE = enum.auto() NODE = enum.auto() COMMANDLINE = enum.auto() INPUT = enum.auto() BACKDROP = enum.auto() class InternalAttributesFactory: BASIC = [ StringParam( name="comment", label="Comments", description="User comments describing this specific node instance.\n" "It is displayed in regular font in the invalidation/comment messages " "tooltip.", value="", semantic="multiline", invalidate=False, ), StringParam( name="label", label="Node's Label", description="Customize the default label (to replace the technical name of the node " "instance).", value="", invalidate=False, ), ChoiceParam( name="nodeDefaultLogLevel", label="Default Logging Level", description="Default logging level for the node (critical, error, warning, info, debug).", value="info", values=VERBOSE_LEVEL, invalidate=False, ), ColorParam( name="color", label="Color", description="Custom color for the node (SVG name or hexadecimal code).", value=lambda node: getattr(node.nodeDesc, "color", ""), invalidate=False, ) ] INVALIDATION = [ StringParam( name="invalidation", label="Invalidation Message", description="A message that will invalidate the node's output folder.\n" "This is useful for development, we can invalidate the output of the node " "when we modify the code.\n" "It is displayed in bold font in the invalidation/comment messages " "tooltip.", value="", semantic="multiline", advanced=True, uidIgnoreValue="", # If the invalidation string is empty, it does not participate to the node's UID ), ] RESIZABLE = [ IntParam( name="fontSize", label="Font Size", description="Size of the font used to display the comments.", value=12, range=(6, 100, 1), invalidate=False, ), ColorParam( name="fontColor", label="Font Color", description="Color of the font used to display the comments (SVG name or hexadecimal code).", value="", invalidate=False, ), IntParam( name="nodeWidth", label="Node Width", description="Width of the node in the graph editor.", value=600, range=None, invalidate=False, enabled=False, # Hidden ), IntParam( name="nodeHeight", label="Node Height", description="Height of the node in the graph editor.", value=400, range=None, invalidate=False, enabled=False, # Hidden ), ] @classmethod def getInternalAttributes(cls, mrNodeType: MrNodeType) -> list[Attribute]: paramMap = { MrNodeType.NONE: cls.BASIC, MrNodeType.BASENODE: cls.INVALIDATION + cls.BASIC, MrNodeType.NODE: cls.INVALIDATION + cls.BASIC, MrNodeType.COMMANDLINE: cls.INVALIDATION + cls.BASIC, MrNodeType.INPUT: cls.BASIC, MrNodeType.BACKDROP: cls.BASIC + cls.RESIZABLE, } return paramMap.get(mrNodeType) class BaseNode(object): """ """ cpu = Level.NORMAL gpu = Level.NONE ram = Level.NORMAL packageName = "" color = "" _mrNodeType: MrNodeType = MrNodeType.BASENODE internalInputs = InternalAttributesFactory.getInternalAttributes(_mrNodeType) inputs = [] outputs = [] size = StaticNodeSize(1) parallelization = None documentation = "" category = "Other" plugin = None # Licenses required to run the plugin # Only used to select machines on the farm when the node is submitted _licenses = [] def __init__(self): super(BaseNode, self).__init__() self.hasDynamicOutputAttribute = any(output.isDynamicValue for output in self.outputs) self.sourceCodeFolder = Path(getfile(self.__class__)).parent.resolve().as_posix() def getMrNodeType(self): return self._mrNodeType @classmethod def resolvedCpu(cls, node): """ Return the resolved CPU level for the given node instance. If `cpu` is a callable, it is called with the node instance as parameter. Otherwise, the static value is returned. """ return cls.cpu(node) if callable(cls.cpu) else cls.cpu @classmethod def resolvedGpu(cls, node): """ Return the resolved GPU level for the given node instance. If `gpu` is a callable, it is called with the node instance as parameter. Otherwise, the static value is returned. """ return cls.gpu(node) if callable(cls.gpu) else cls.gpu @classmethod def resolvedRam(cls, node): """ Return the resolved RAM level for the given node instance. If `ram` is a callable, it is called with the node instance as parameter. Otherwise, the static value is returned. """ return cls.ram(node) if callable(cls.ram) else cls.ram @classmethod def resolvedSize(cls, node): """ Return the resolved size for the given node instance. If `size` is a callable, it is called with the node instance as parameter. If `size` is an integer, it is returned as-is. Objects with a `computeSize` method are supported for backward compatibility. """ if callable(cls.size): return cls.size(node) if isinstance(cls.size, int): return cls.size # Backward compatibility with external size classes using computeSize instead of __call__ if hasattr(cls.size, 'computeSize'): logging.warning(f"The plugin '{node.nodeType}' should use a callable instead of the deprecated method 'computeSize'.") return cls.size.computeSize(node) raise ValueError(f"{node.name} size attribute is invalid") def upgradeAttributeValues(self, attrValues, fromVersion): return attrValues @classmethod def onNodeCreated(cls, node): """ Called after a node instance created from this node descriptor has been added to a Graph. """ pass @classmethod def update(cls, node): """ Method call before node's internal update on invalidation. Args: node: the BaseNode instance being updated See Also: BaseNode.updateInternals """ pass @classmethod def postUpdate(cls, node): """ Method call after node's internal update on invalidation. Args: node: the BaseNode instance being updated See Also: NodeBase.updateInternals """ pass def preprocess(self, node): """ Gets invoked just before the processChunk method for the node. Args: node: The BaseNode instance about to be processed. """ pass def postprocess(self, node): """ Gets invoked after the processChunk method for the node. Args: node: The BaseNode instance which is processed. """ pass def process(self, node): raise NotImplementedError(f'No process implementation on node: "{node.name}"') def processChunk(self, chunk): if self.parallelization is None: self.process(chunk.node) else: raise NotImplementedError(f'No process implementation on node: "{chunk.node.name}"') def executeChunkCommandLine(self, chunk, cmd, env=None): try: with open(chunk.getLogFile(), 'a') as logF: chunk.status.commandLine = cmd chunk.saveStatusFile() cmdList = shlex.split(cmd) # Resolve executable to full path prog = shutil.which(cmdList[0], path=env.get("PATH") if env else None) print(f"Starting Process for '{chunk.node.name}'") print(f" - commandLine: {cmd}") print(f" - logFile: {chunk.getLogFile()}") if prog: cmdList[0] = Path(prog).as_posix() print(f" - command full path: {cmdList[0]}") # Change the process group to avoid Meshroom main process being killed if the # subprocess gets terminated by the user or an Out Of Memory (OOM kill). if sys.platform == "win32": from subprocess import CREATE_NEW_PROCESS_GROUP platformArgs = {"creationflags": CREATE_NEW_PROCESS_GROUP} # Note: DETACHED_PROCESS means fully detached process. # We do not want a fully detached process to ensure that if Meshroom is killed, # the subprocesses are killed too. else: platformArgs = {"start_new_session": True} # Note: "preexec_fn"=os.setsid is the old way before python-3.2 chunk.subprocess = psutil.Popen( cmdList, stdout=logF, stderr=logF, cwd=chunk.node.internalFolder, env=env, text=True, **platformArgs, ) exitCleanup.addSubprocess(chunk.subprocess) if hasattr(chunk, "statThread"): # We only have a statThread if the node is running in the current process # and not in a dedicated environment/process. chunk.statThread.proc = chunk.subprocess stdout, stderr = chunk.subprocess.communicate() chunk.status.returnCode = chunk.subprocess.returncode if chunk.subprocess.returncode and chunk.subprocess.returncode < 0: signal_num = -chunk.subprocess.returncode logF.write(f"Process was killed by signal: {signal_num}") try: status = chunk.subprocess.status() logF.write(f"Process status: {status}") except Exception: pass if chunk.subprocess.returncode != 0: with open(chunk.getLogFile(), "r") as logF: logContent = "".join(logF.readlines()) raise RuntimeError(f'Error on node "{chunk.name}":\nLog:\n{logContent}') finally: chunk.subprocess = None def stopProcess(self, chunk): # The same node could exists several times in the graph and # only one would have the running subprocess; ignore all others if not chunk.subprocess: logging.warning(f"[{chunk.node.name}] stopProcess: no subprocess") return # Retrieve process tree processes = chunk.subprocess.children(recursive=True) + [chunk.subprocess] logging.debug(f"[{chunk.node.name}] Processes to stop: {len(processes)}") for process in processes: try: # With terminate, the process has a chance to handle cleanup process.terminate() except psutil.NoSuchProcess: pass # If it is still running, force kill it for process in processes: try: # Use is_running() instead of poll() as we use a psutil.Process object if process.is_running(): # Check if process is still alive process.kill() # Forcefully kill it except psutil.NoSuchProcess: logging.info(f"[{chunk.node.name}] Process already terminated.") except psutil.AccessDenied: logging.info(f"[{chunk.node.name}] Permission denied to kill the process.") class InputNode(BaseNode): """ Node that does not need to be processed, it is just a placeholder for inputs. """ _mrNodeType: MrNodeType = MrNodeType.INPUT internalInputs = InternalAttributesFactory.getInternalAttributes(_mrNodeType) def __init__(self): super(InputNode, self).__init__() def getMrNodeType(self): return self._mrNodeType def processChunk(self, chunk): pass def process(self, node): pass class BackdropNode(BaseNode): """ Node that does not need to be processed, it is just a placeholder for grouping other nodes. """ _mrNodeType: MrNodeType = MrNodeType.BACKDROP internalInputs = InternalAttributesFactory.getInternalAttributes(_mrNodeType) def __init__(self): super(BackdropNode, self).__init__() def getMrNodeType(self): return self._mrNodeType def processChunk(self, chunk): pass def process(self, node): pass class Node(BaseNode): pythonExecutable = "python" _mrNodeType: MrNodeType = MrNodeType.NODE def __init__(self): super(Node, self).__init__() def getMrNodeType(self): return self._mrNodeType def processChunkInEnvironment(self, chunk): meshroomComputeCmd = f"{chunk.node.nodeDesc.pythonExecutable} {_MESHROOM_COMPUTE}" + \ f" \"{chunk.node.graph.filepath}\" --node {chunk.node.name}" + \ " --extern --inCurrentEnv" if len(chunk.node.getChunks()) > 1: meshroomComputeCmd += f" --iteration {chunk.range.iteration}" runtimeEnv = chunk.node.nodeDesc.plugin.runtimeEnv cmdPrefix = chunk.node.nodeDesc.plugin.commandPrefix cmdSuffix = chunk.node.nodeDesc.plugin.commandSuffix self.executeChunkCommandLine(chunk, cmdPrefix + meshroomComputeCmd + cmdSuffix, env=runtimeEnv) class CommandLineNode(BaseNode): """ """ commandLine = "" # need to be defined on the node parallelization = None commandLineRange = "" _mrNodeType: MrNodeType = MrNodeType.COMMANDLINE def __init__(self): super(CommandLineNode, self).__init__() def getMrNodeType(self): return self._mrNodeType def buildCommandLine(self, chunk) -> str: cmdLineVars = chunk.node.createCmdLineVars() cmdPrefix = "" cmdSuffix = "" if chunk.node.nodeDesc.plugin: cmdPrefix = chunk.node.nodeDesc.plugin.commandPrefix cmdSuffix = chunk.node.nodeDesc.plugin.commandSuffix if chunk.node.isParallelized and chunk.node.size > 1: cmdSuffix = " " + self.commandLineRange.format(**chunk.range.toDict()) + " " + cmdSuffix # In the case of a lambda, we want a single "node" argument and not the node descriptor "self". # Therefore, we use getattr_static to retrieve the raw lambda instead of a bound method, which # would impose "self" as the first argument if we accessed "self.commandLine". commandLineValue = getattr_static(self, 'commandLine') if callable(commandLineValue): cmd = commandLineValue(chunk.node) else: cmd = commandLineValue.format(**chunk.node._expVars, **chunk.node._staticExpVars, **cmdLineVars) return cmdPrefix + cmd + cmdSuffix def processChunk(self, chunk): cmd = self.buildCommandLine(chunk) runtimeEnv = chunk.node.nodeDesc.plugin.runtimeEnv self.executeChunkCommandLine(chunk, cmd, env=runtimeEnv) # Specific command line node for AliceVision apps class AVCommandLineNode(CommandLineNode): cgroupParsed = False cmdMem = "" cmdCore = "" def __init__(self): super(AVCommandLineNode, self).__init__() if AVCommandLineNode.cgroupParsed is False: AVCommandLineNode.cmdMem = "" memSize = cgroup.getCgroupMemorySize() if memSize > 0: AVCommandLineNode.cmdMem = f" --maxMemory={memSize}" AVCommandLineNode.cmdCore = "" coresCount = cgroup.getCgroupCpuCount() if coresCount > 0: AVCommandLineNode.cmdCore = f" --maxCores={coresCount}" AVCommandLineNode.cgroupParsed = True def buildCommandLine(self, chunk) -> str: commandLineString = super(AVCommandLineNode, self).buildCommandLine(chunk) return commandLineString + AVCommandLineNode.cmdMem + AVCommandLineNode.cmdCore class InitNode(object): def __init__(self): super(InitNode, self).__init__() def initialize(self, node, inputs, recursiveInputs): """ Initialize the attributes that are needed for a node to start running. Args: node (Node): the node whose attributes must be initialized inputs (list): the user-provided list of input files/directories recursiveInputs (list): the user-provided list of input directories to search recursively for images """ pass def resetAttributes(self, node, attributeNames): """ Reset the values of the provided attributes for a node. Args: node (Node): the node whose attributes are to be reset attributeNames (list): the list containing the names of the attributes to reset """ for attrName in attributeNames: if node.hasAttribute(attrName): node.attribute(attrName).resetToDefaultValue() def extendAttributes(self, node, attributesDict): """ Extend the values of the provided attributes for a node. Args: node (Node): the node whose attributes are to be extended attributesDict (dict): the dictionary containing the attributes' names (as keys) and the values to extend with """ for attr in attributesDict.keys(): if node.hasAttribute(attr): node.attribute(attr).extend(attributesDict[attr]) def setAttributes(self, node, attributesDict): """ Set the values of the provided attributes for a node. Args: node (Node): the node whose attributes are to be extended attributesDict (dict): the dictionary containing the attributes' names (as keys) and the values to set """ for attr in attributesDict: if node.hasAttribute(attr): node.attribute(attr).value = attributesDict[attr] ================================================ FILE: meshroom/core/desc/shapeAttribute.py ================================================ from meshroom.core.desc import ListAttribute, GroupAttribute, StringParam, FloatParam, Geometry, Size2d, Vec2d class Shape(GroupAttribute): """ Base attribute for all Shape attribute. Countains several attributes (inherit from GroupAttribute). """ def __init__(self, geometryItems, name, label, description, commandLineGroup="allParams", advanced=False, semantic="", enabled=True, visible=True, exposed=False): # Shape group desciption items = [ StringParam(name="userName", label="User Name", description="User shape name.", value="", commandLineGroup=commandLineGroup, advanced=advanced, enabled=enabled, visible=visible, exposed=exposed), StringParam(name="userColor", label="User Color", description="User shape color.", value="#2a82da", commandLineGroup=commandLineGroup, advanced=advanced, enabled=enabled, visible=visible, exposed=exposed), Geometry(geometryItems, name="geometry", label="Geometry", description="Shape geometry.", commandLineGroup=commandLineGroup, advanced=advanced, enabled=enabled, visible=visible, exposed=exposed) ] # GroupAttribute constructor super(Shape, self).__init__(items=items, name=name, label=label, description=description, commandLineGroup=commandLineGroup, advanced=advanced, semantic=semantic, enabled=enabled, visible=visible, exposed=exposed) def getInstanceType(self): """ Return the correct Attribute instance corresponding to the description. """ # Import within the method to prevent cyclic dependencies from meshroom.core.attribute import ShapeAttribute return ShapeAttribute class ShapeList(ListAttribute): """ List attribute of Shape attribute. Countains several attributes (inherit from ListAttribute). """ def __init__(self, shape: Shape, name, label, description, commandLineGroup="allParams", advanced=False, semantic="", enabled=True, visible=True, exposed=False): # ListAttribute constructor super(ShapeList, self).__init__(elementDesc=shape, name=name, label=label, description=description, commandLineGroup=commandLineGroup, advanced=advanced, semantic=semantic, enabled=enabled, visible=visible, exposed=exposed) def getInstanceType(self): """ Return the correct Attribute instance corresponding to the description. """ # Import within the method to prevent cyclic dependencies from meshroom.core.attribute import ShapeListAttribute return ShapeListAttribute class Point2d(Shape): """ Point2d is a Shape attribute that allows to display and modify a 2d point. """ def __init__(self, name, label, description, keyable=False, keyType=None, commandLineGroup="allParams", advanced=False, semantic="", enabled=True, visible=True, exposed=False): # Geometry group desciption geometryItems = [ FloatParam(name="x", label="X", description="X coordinate.", value=-1.0, keyable=keyable, keyType=keyType, commandLineGroup=commandLineGroup, advanced=advanced, enabled=enabled, visible=visible, exposed=exposed), FloatParam(name="y", label="Y", description="Y coordinate.", value=-1.0, keyable=keyable, keyType=keyType, commandLineGroup=commandLineGroup, advanced=advanced, enabled=enabled, visible=visible, exposed=exposed) ] # ShapeAttribute constructor super(Point2d, self).__init__(geometryItems, name, label, description, commandLineGroup=None, advanced=advanced, semantic=semantic, enabled=enabled, visible=visible, exposed=exposed) class Line2d(Shape): """ Line2d is a Shape attribute that allows to display and modify a 2d line. """ def __init__(self, name, label, description, keyable=False, keyType=None, commandLineGroup="allParams", advanced=False, semantic="", enabled=True, visible=True, exposed=False): # Geometry group desciption geometryItems = [ Vec2d(name="a", label="A", description="Line A point.", x=-1.0, y=-1.0, keyable=keyable, keyType=keyType, commandLineGroup=commandLineGroup, advanced=advanced, enabled=enabled, visible=visible, exposed=exposed), Vec2d(name="b", label="B", description="Line B point.", x=-1.0, y=-1.0, keyable=keyable, keyType=keyType, commandLineGroup=commandLineGroup, advanced=advanced, enabled=enabled, visible=visible, exposed=exposed) ] # ShapeAttribute constructor super(Line2d, self).__init__(geometryItems, name, label, description, commandLineGroup=None, advanced=advanced, semantic=semantic, enabled=enabled, visible=visible, exposed=exposed) class Rectangle(Shape): """ Rectangle is a Shape attribute that allows to display and modify a rectangle. """ def __init__(self, name, label, description, keyable=False, keyType=None, commandLineGroup="allParams", advanced=False, semantic="", enabled=True, visible=True, exposed=False): # Geometry group desciption geometryItems = [ Vec2d(name="center", label="Center", description="Rectangle center.", x=-1.0, y=-1.0, keyable=keyable, keyType=keyType, commandLineGroup=commandLineGroup, advanced=advanced, enabled=enabled, visible=visible, exposed=exposed), Size2d(name="size", label="Size", description="Rectangle size.", width=-1.0, height=-1.0, keyable=keyable, keyType=keyType, commandLineGroup=commandLineGroup, advanced=advanced, enabled=enabled, visible=visible, exposed=exposed) ] # ShapeAttribute constructor super(Rectangle, self).__init__(geometryItems, name, label, description, commandLineGroup=None, advanced=advanced, semantic=semantic, enabled=enabled, visible=visible, exposed=exposed) class Circle(Shape): """ Circle is a Shape attribute that allows to display and modify a circle. """ def __init__(self, name, label, description, keyable=False, keyType=None, commandLineGroup="allParams", advanced=False, semantic="", enabled=True, visible=True, exposed=False): # Geometry group desciption geometryItems = [ Vec2d(name="center", label="Center", description="Circle center.", x=-1.0, y=-1.0, keyable=keyable, keyType=keyType, commandLineGroup=commandLineGroup, advanced=advanced, enabled=enabled, visible=visible, exposed=exposed), FloatParam(name="radius", label="Radius", description="Circle radius.", value=-1.0, keyable=keyable, keyType=keyType, commandLineGroup=commandLineGroup, advanced=advanced, enabled=enabled, visible=visible, exposed=exposed) ] # ShapeAttribute constructor super(Circle, self).__init__(geometryItems, name, label, description, commandLineGroup=None, advanced=advanced, semantic=semantic, enabled=enabled, visible=visible, exposed=exposed) ================================================ FILE: meshroom/core/evaluation.py ================================================ #!/usr/bin/env python import ast, math class MathEvaluator: """ Evaluate math expressions ..code::py # Example usage mev = MathEvaluator() print(mev.evaluate("e-1+cos(2*pi)")) print(mev.evaluate("pow(2, 8)")) print(mev.evaluate("round(sin(pi), 3)")) """ # Allowed math symbols allowed_symbols = { "e": math.e, "pi": math.pi, "cos": math.cos, "sin": math.sin, "tan": math.tan, "exp": math.exp, "pow": pow, "round": round, "abs": abs, "min": min, "max": max, "sqrt": math.sqrt, "log": math.log } # Allowed AST node types allowed_nodes = ( ast.Expression, ast.BinOp, ast.UnaryOp, ast.Call, ast.Name, ast.Load, ast.Add, ast.Sub, ast.Mult, ast.Div, ast.Pow, ast.Mod, ast.FloorDiv, ast.USub, ast.UAdd, ast.BitXor, ast.BitOr, ast.BitAnd, ast.LShift, ast.RShift, ast.Invert, ast.Constant ) def _validate_ast(self, node): for child in ast.walk(node): if not isinstance(child, self.allowed_nodes): raise ValueError(f"Bad expression: {ast.dump(child)}") # Check that all variable/function names are whitelisted if isinstance(child, ast.Name): if child.id not in self.allowed_symbols: raise ValueError(f"Unknown symbol: {child.id}") def evaluate(self, expr: str): if any(bad in expr for bad in ('\n', '#')): raise ValueError(f"Invalid expression: {expr}") try: node = ast.parse(expr.strip(), mode="eval") self._validate_ast(node) return eval(compile(node, "", "eval"), {"__builtins__": {}}, self.allowed_symbols) except Exception: raise ValueError(f"Invalid expression: {expr}") ================================================ FILE: meshroom/core/exception.py ================================================ #!/usr/bin/env python class MeshroomException(Exception): """ Base class for Meshroom exceptions """ pass class GraphException(MeshroomException): """ Base class for Graph exceptions """ pass class InvalidEdgeError(GraphException): """ Raised when an edge between two attributes cannot be created. """ def __init__(self, srcAttrName: str, dstAttrName: str, msg: str) -> None: super().__init__(f"Failed to connect {srcAttrName}->{dstAttrName}: {msg}") class GraphCompatibilityError(GraphException): """ Raised when node compatibility issues occur when loading a graph. Args: filepath: The path to the file that caused the error. issues: A dictionnary of node names and their respective compatibility issues. """ def __init__(self, filepath, issues: dict[str, str]) -> None: self.filepath = filepath self.issues = issues msg = f"Compatibility issues found when loading {self.filepath}: {self.issues}" super().__init__(msg) class UnknownNodeTypeError(GraphException): """ Raised when asked to create a unknown node type. """ def __init__(self, nodeType, msg=None): msg = "Unknown Node Type: " + nodeType super().__init__(msg) self.nodeType = nodeType class NodeUpgradeError(GraphException): def __init__(self, nodeName, details=None): msg = f"Failed to upgrade node {nodeName}" if details: msg += f": {details}" super().__init__(msg) class GraphVisitMessage(GraphException): """ Base class for sending messages via exceptions during a graph visit. """ pass class StopGraphVisit(GraphVisitMessage): """ Immediately interrupt graph visit. """ pass class StopBranchVisit(GraphVisitMessage): """ Immediately stop branch visit. """ pass class CyclicDependencyError(GraphVisitMessage): """ Do not start visiting the graph. """ pass ================================================ FILE: meshroom/core/fileUtils.py ================================================ import os import re pattern = r"(?P.*?)(?P[-._]\d+)?(?P\.\w{3,4})" compiled_pattern = re.compile(pattern) compiled_frameId = re.compile(r"(\D+)?(?P\d+$)") def getFileElements(inputFilePath: str): filename = os.path.basename(inputFilePath) match = compiled_pattern.fullmatch(filename) frameId_str = match.group("FRAMEID_STR") fileElements = {} if match: fileElements = { "": inputFilePath, "": filename, "": match.group("FILESTEM_PREFIX"), "": match.group("FILESTEM_PREFIX"), "": match.group("EXTENSION"), } if frameId_str is not None: fileElements[""] = frameId_str fileElements[""] += frameId_str match_frameId = compiled_frameId.search(frameId_str) fileElements[""] = match_frameId.group("FRAMEID") return fileElements def getViewElements(vp): vpPath = vp.childAttribute("path").value viewElements = getFileElements(vpPath) viewElements[""] = str(vp.childAttribute("viewId").value) viewElements[""] = str(vp.childAttribute("intrinsicId").value) viewElements[""] = str(vp.childAttribute("poseId").value) return viewElements def replacePatterns(input, pattern, replacements): # Use all substrings of "input" matching the regex "pattern" as a key to substitute themselves by their value in the dictionary "replacements". # If "replacements" does not contain the key, the key is removed from "input" to build the resolved string. def replaceMatch(match): key = match.group() return replacements.get(key, "") return pattern.sub(replaceMatch, input) compiled_element = re.compile(r"<\w*>") def resolvePath(input, outputTemplate: str) -> str: if isinstance(input, str): replacements = getFileElements(input) else: replacements = getViewElements(input) resolved = replacePatterns(outputTemplate, compiled_element, replacements) return resolved ================================================ FILE: meshroom/core/graph.py ================================================ import json import logging import os import re from typing import Any, Optional from collections.abc import Iterable from collections import defaultdict, OrderedDict import weakref from contextlib import contextmanager from pathlib import Path from enum import Enum import meshroom import meshroom.core from meshroom.common import BaseObject, DictModel, Slot, Signal, Property from meshroom.core import Version from meshroom.core import submitters from meshroom.core.attribute import Attribute, ListAttribute, GroupAttribute from meshroom.core.exception import GraphCompatibilityError, InvalidEdgeError, StopGraphVisit, StopBranchVisit, CyclicDependencyError 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 from meshroom.core.mtyping import PathLike from meshroom.core.submitter import BaseSubmittedJob, jobManager # Replace default encoder to support Enums DefaultJSONEncoder = json.JSONEncoder # store the original one class MyJSONEncoder(DefaultJSONEncoder): # declare a new one with Enum support def default(self, obj): if isinstance(obj, Enum): return obj.name return DefaultJSONEncoder.default(self, obj) # use the default one for all other types json.JSONEncoder = MyJSONEncoder # replace the default implementation with our new one @contextmanager def GraphModification(graph): """ A Context Manager that can be used to trigger only one Graph update for a group of several modifications. GraphModifications can be nested. """ if not isinstance(graph, Graph): raise ValueError("GraphModification expects a Graph instance") # Store update policy for nested usage enabled = graph.updateEnabled # Disable graph update for nested block # (does nothing if already disabled) graph.updateEnabled = False try: yield # Execute nested block except Exception: raise finally: # Restore update policy graph.updateEnabled = enabled class Edge(BaseObject): def __init__(self, src, dst, parent=None): super().__init__(parent) self._src = weakref.ref(src) self._dst = weakref.ref(dst) self._repr = f" {self._src()} -> {self._dst()}" @property def src(self): return self._src() @property def dst(self): return self._dst() src = Property(Attribute, src.fget, constant=True) dst = Property(Attribute, dst.fget, constant=True) WHITE = 0 GRAY = 1 BLACK = 2 class Visitor: """ Base class for Graph Visitors that does nothing. Sub-classes can override any method to implement specific algorithms. """ def __init__(self, reverse, dependenciesOnly): super().__init__() self.reverse = reverse self.dependenciesOnly = dependenciesOnly # def initializeVertex(self, s, g): # '''is invoked on every vertex of the graph before the start of the graph search.''' # pass # def startVertex(self, s, g): # '''is invoked on the source vertex once before the start of the search.''' # pass def discoverVertex(self, u, g): """ Is invoked when a vertex is encountered for the first time. """ pass def examineEdge(self, e, g): """ Is invoked on every out-edge of each vertex after it is discovered.""" pass def treeEdge(self, e, g): """ Is invoked on each edge as it becomes a member of the edges that form the search tree. If you wish to record predecessors, do so at this event point. """ pass def backEdge(self, e, g): """ Is invoked on the back edges in the graph. """ pass def forwardOrCrossEdge(self, e, g): """ Is invoked on forward or cross edges in the graph. In an undirected graph this method is never called. """ pass def finishEdge(self, e, g): """ Is invoked on the non-tree edges in the graph as well as on each tree edge after its target vertex is finished. """ pass def finishVertex(self, u, g): """ Is invoked on a vertex after all of its out edges have been added to the search tree and all of the adjacent vertices have been discovered (but before their out-edges have been examined). """ pass def changeTopology(func): """ Graph methods modifying the graph topology (add/remove edges or nodes) must be decorated with 'changeTopology' for update mechanism to work as intended. """ def decorator(self, *args, **kwargs): assert isinstance(self, Graph) # call method result = func(self, *args, **kwargs) # mark graph dirty self.dirtyTopology = True # request graph update self.update() return result return decorator def blockNodeCallbacks(func): """ Graph methods loading serialized graph content must be decorated with 'blockNodeCallbacks', to avoid attribute changed callbacks defined on node descriptions to be triggered during this process. """ def inner(self, *args, **kwargs): self._loading = True try: return func(self, *args, **kwargs) finally: self._loading = False return inner def generateTempProjectFilepath(tmpFolder=None): """ Generate a temporary project filepath. This method is used to generate a temporary project file for the current graph. """ from datetime import datetime if tmpFolder is 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") class Graph(BaseObject): """ _________________ _________________ _________________ | | | | | | | Node A | | Node B | | Node C | | | edge | | edge | | |input output|>---->|input output|>---->|input output| |_______________| |_______________| |_______________| Data structures: nodes = {'A': , 'B': , 'C': } edges = {B.input: A.output, C.input: B.output,} """ def __init__(self, name: str = "", parent: BaseObject = None): super().__init__(parent) self.name: str = name self._loading: bool = False self._saving: bool = False self._updateEnabled: bool = True self._updateRequested: bool = False self.dirtyTopology: bool = False self._nodesMinMaxDepths = {} self._computationBlocked = {} self._canComputeLeaves: bool = True self._nodes = DictModel(keyAttrName='name', parent=self) # Edges: use dst attribute as unique key since it can only have one input connection self._edges = DictModel(keyAttrName='dst', parent=self) self._compatibilityNodes = DictModel(keyAttrName='name', parent=self) self._cacheDir: str = '' self._filepath: str = '' self._fileDateVersion = 0 self.header = {} def clear(self): self._clearGraphContent() self.header.clear() self._unsetFilepath() def _clearGraphContent(self): self._edges.clear() # Tell QML nodes are going to be deleted for node in self._nodes: node.alive = False self._nodes.clear() self._compatibilityNodes.clear() @property def fileFeatures(self): """ Get loaded file supported features based on its version. """ return GraphIO.getFeaturesForVersion(self.header.get(GraphIO.Keys.FileVersion, "0.0")) @property def isLoading(self): """ Return True if the graph is currently being loaded. """ return self._loading @property def isSaving(self): """ Return True if the graph is currently being saved. """ return self._saving @Slot(str) def load(self, filepath: PathLike): """ Load a Meshroom Graph ".mg" file in place. Args: filepath: The path to the Meshroom Graph file to load. """ self._setFilepath(filepath) self._deserialize(Graph._loadGraphData(filepath)) self._fileDateVersion = os.path.getmtime(filepath) def initFromTemplate(self, filepath: PathLike, copyOutputs: bool = False): """ Deserialize a template Meshroom Graph ".mg" file in place. When initializing from a template, the internal filepath of the graph instance is not set. Saving the file on disk will require to specify a filepath. Args: filepath: The path to the Meshroom Graph file to load. copyOutputs: (optional) Whether to keep 'CopyFiles' nodes. """ self._deserialize(Graph._loadGraphData(filepath)) # Creating nodes from a template is conceptually similar to explicit node creation, # therefore the nodes descriptors' "onNodeCreated" callback is triggered for each # node instance created by this process. self._triggerNodeCreatedCallback(self.nodes) if not copyOutputs: with GraphModification(self): for node in [node for node in self.nodes if node.nodeType == "CopyFiles"]: self.removeNode(node.name) @staticmethod def _loadGraphData(filepath: PathLike) -> dict: """Deserialize the content of the Meshroom Graph file at `filepath` to a dictionnary.""" with open(filepath) as file: graphData = json.load(file) return graphData @blockNodeCallbacks def _deserialize(self, graphData: dict): """Deserialize `graphData` in the current Graph instance. Args: graphData: The serialized Graph. """ self._clearGraphContent() self.header.clear() self.header = graphData.get(GraphIO.Keys.Header, {}) fileVersion = Version(self.header.get(GraphIO.Keys.FileVersion, "0.0")) graphContent = self._normalizeGraphContent(graphData, fileVersion) isTemplate = self.header.get(GraphIO.Keys.Template, False) with GraphModification(self): # iterate over nodes sorted by suffix index in their names for nodeName, nodeData in sorted( graphContent.items(), key=lambda x: self.getNodeIndexFromName(x[0]) ): self._deserializeNode(nodeData, nodeName, self) # Create graph edges by resolving attributes expressions self._applyExpr() # Templates are specific: they contain only the minimal amount of # serialized data to describe the graph structure. # They are not meant to be computed: therefore, we can early return here, # as uid conflict evaluation is only meaningful for nodes with computed data. if isTemplate: return # By this point, the graph has been fully loaded and an updateInternals has been triggered, so all the # nodes' links have been resolved and their UID computations are all complete. # It is now possible to check whether the UIDs stored in the graph file for each node correspond to the ones # that were computed. self._evaluateUidConflicts(graphContent) def _normalizeGraphContent(self, graphData: dict, fileVersion: Version) -> dict: graphContent = graphData.get(GraphIO.Keys.Graph, graphData) if fileVersion < Version("2.0"): # For internal folders, all "{uid0}" keys should be replaced with "{uid}" updatedFileData = json.dumps(graphContent).replace("{uid0}", "{uid}") # For fileVersion < 2.0, the nodes' UID is stored as: # "uids": {"0": "hashvalue"} # These should be identified and replaced with: # "uid": "hashvalue" uidPattern = re.compile(r'"uids": \{"0":.*?\}') uidOccurrences = uidPattern.findall(updatedFileData) for occ in uidOccurrences: uid = occ.split("\"")[-2] # UID is second to last element newUidStr = fr'"uid": "{uid}"' updatedFileData = updatedFileData.replace(occ, newUidStr) graphContent = json.loads(updatedFileData) return graphContent def _deserializeNode(self, nodeData: dict, nodeName: str, fromGraph: "Graph"): # Retrieve version info from: # 1. nodeData: node saved from a CompatibilityNode # 2. nodesVersion in file header: node saved from a Node # If unvailable, the "version" field will not be set in `nodeData`. if "version" not in nodeData: if version := fromGraph._getNodeTypeVersionFromHeader(nodeData["nodeType"]): nodeData["version"] = version inTemplate = fromGraph.header.get(GraphIO.Keys.Template, False) node = nodeFactory(nodeData, nodeName, inTemplate=inTemplate) self._addNode(node, nodeName) return node def _getNodeTypeVersionFromHeader(self, nodeType: str, default: Optional[str] = None) -> Optional[str]: nodeVersions = self.header.get(GraphIO.Keys.NodesVersions, {}) return nodeVersions.get(nodeType, default) def _evaluateUidConflicts(self, graphContent: dict): """ Compare the computed UIDs of all the nodes in the graph with the UIDs serialized in `graphContent`. If there are mismatches, the nodes with the unexpected UID are replaced with "UidConflict" compatibility nodes. Args: graphContent: The serialized Graph content. """ def _serializedNodeUidMatchesComputedUid(nodeData: dict, node: BaseNode) -> bool: """ Returns whether the serialized UID matches the one computed in the `node` instance. """ if isinstance(node, CompatibilityNode): return True serializedUid = nodeData.get("uid", None) computedUid = node._uid return serializedUid is None or computedUid is None or serializedUid == computedUid uidConflictingNodes = [ node for node in self.nodes if not _serializedNodeUidMatchesComputedUid(graphContent[node.name], node) ] if not uidConflictingNodes: return logging.warning("UID Compatibility issues found: recreating conflicting nodes as CompatibilityNodes.") # A uid conflict is contagious: if a node has a uid conflict, all of its downstream nodes may be # impacted as well, as the uid flows through connections. # Therefore, we deal with conflicting uid nodes by depth: replacing a node with a CompatibilityNode restores # the serialized uid, which might solve "false-positives" downstream conflicts as well. nodesSortedByDepth = sorted(uidConflictingNodes, key=lambda node: node.minDepth) for node in nodesSortedByDepth: nodeData = graphContent[node.name] # Evaluate if the node uid is still conflicting at this point, or if it has been resolved by an # upstream node replacement. if _serializedNodeUidMatchesComputedUid(nodeData, node): continue expectedUid = node._uid compatibilityNode = nodeFactory(graphContent[node.name], node.name, expectedUid=expectedUid) # This operation will trigger a graph update that will recompute the uids of all nodes, # allowing the iterative resolution of uid conflicts. self.replaceNode(node.name, compatibilityNode) def importGraphContentFromFile(self, filepath: PathLike) -> list[Node]: """Import the content (nodes and edges) of another Graph file into this Graph instance. Args: filepath: The path to the Graph file to import. Returns: The list of newly created Nodes. """ graph = loadGraph(filepath) return self.importGraphContent(graph) @blockNodeCallbacks def importGraphContent(self, graph: "Graph") -> list[Node]: """ Import the content (node and edges) of another `graph` into this Graph instance. Nodes are imported with their original names if possible, otherwise a new unique name is generated from their node type. Args: graph: The graph to import. Returns: The list of newly created Nodes. """ def _renameClashingNodes(): if not self.nodes: return unavailableNames = set(self.nodes.keys()) for node in graph.nodes: if node._name in unavailableNames: node._name = self._createUniqueNodeName(node.nodeType, unavailableNames) unavailableNames.add(node._name) def _importNodesAndEdges() -> list[Node]: importedNodes = [] # If we import the content of the graph within itself, # iterate over a copy of the nodes as the graph is modified during the iteration. nodes = graph.nodes if graph is not self else list(graph.nodes) with GraphModification(self): for srcNode in nodes: node = self._deserializeNode(srcNode.toDict(), srcNode.name, graph) importedNodes.append(node) self._applyExpr() return importedNodes _renameClashingNodes() importedNodes = _importNodesAndEdges() return importedNodes @property def updateEnabled(self): return self._updateEnabled @updateEnabled.setter def updateEnabled(self, enabled): self._updateEnabled = enabled if enabled and self._updateRequested: # Trigger an update if requested while disabled self.update() self._updateRequested = False @changeTopology def _addNode(self, node, uniqueName): """ Internal method to add the given node to this Graph, with the given name (must be unique). Attribute expressions are not resolved. """ if node.graph is not None and node.graph != self: raise RuntimeError( 'Node "{}" cannot be part of the Graph "{}", as it is already part of the other graph "{}".'.format( node.nodeType, self.name, node.graph.name)) assert uniqueName not in self._nodes.keys() node._name = uniqueName node.graph = self self._nodes.add(node) node.chunksChanged.connect(self.updated) def addNode(self, node, uniqueName=None): """ Add the given node to this Graph with an optional unique name, and resolve attributes expressions. """ self._addNode(node, uniqueName if uniqueName else self._createUniqueNodeName(node.nodeType)) # Resolve attribute expressions with GraphModification(self): node._applyExpr() return node def renameNode(self, node: Node, newName: str): """ Rename a node in the Node Graph. If the proposed name is already assigned to a node then it will create a unique name Args: node (Node): Node to rename. newName (str): New name of the node. """ # Handle empty string if not newName: return if node.getLocked(): logging.warning(f"Cannot rename node {node} because of the locked status") return usedNames = {n._name for n in self._nodes if n != node} # Make sure we rename to an available name if newName in usedNames: newName = self._createUniqueNodeName(newName, usedNames) # Rename in the dict model self._nodes.rename(node._name, newName) # Finally rename the node name property and notify Qt node._name = newName node.nodeNameChanged.emit() def copyNode(self, srcNode: Node, withEdges: bool=False): """ Get a copy instance of a node outside the graph. Args: srcNode: the node to copy withEdges: whether to copy edges Returns: The created node instance and the mapping of skipped edges per attribute (always empty if `withEdges` is True) """ def _removeLinkExpressions(attribute: Attribute, removed: dict[Attribute, str]): """ Recursively remove link expressions from the given root `attribute`. """ # Link expressions are only stored on input attributes if attribute.isOutput: return if attribute._linkExpression: removed[attribute] = attribute._linkExpression attribute._linkExpression = None elif isinstance(attribute, (ListAttribute, GroupAttribute)): for child in attribute.value: _removeLinkExpressions(child, removed) with GraphModification(self): node = nodeFactory(srcNode.toDict(), name=srcNode.nodeType) skippedEdges = {} if not withEdges: for _, attr in node.attributes.items(): _removeLinkExpressions(attr, skippedEdges) return node, skippedEdges def duplicateNodes(self, srcNodes): """ Duplicate nodes in the graph with their connections. Args: srcNodes: the nodes to duplicate Returns: OrderedDict[Node, Node]: the source->duplicate map """ # use OrderedDict to keep duplicated nodes creation order duplicates = OrderedDict() with GraphModification(self): duplicateEdges = {} # first, duplicate all nodes without edges and keep a 'source=>duplicate' map # keeps tracks of non-created edges for later remap for srcNode in srcNodes: node, edges = self.copyNode(srcNode, withEdges=False) duplicate = self.addNode(node) duplicateEdges.update(edges) duplicates.setdefault(srcNode, []).append(duplicate) # re-create edges taking into account what has been duplicated for attr, linkExpression in duplicateEdges.items(): # logging.warning("attr={} linkExpression={}".format(attr.rootName, linkExpression)) link = linkExpression[1:-1] # remove starting '{' and trailing '}' # get source node and attribute name edgeSrcNodeName, edgeSrcAttrName = link.split(".", 1) edgeSrcNode = self.node(edgeSrcNodeName) # if the edge's source node has been duplicated (the key exists in the dictionary), # use the duplicate; otherwise use the original node if edgeSrcNode in duplicates: edgeSrcNode = duplicates.get(edgeSrcNode)[0] self.addEdge(edgeSrcNode.attribute(edgeSrcAttrName), attr) return duplicates def outEdges(self, attribute): """ Return the list of edges starting from the given attribute """ # type: (Attribute,) -> [Edge] return [edge for edge in self.edges if edge.src == attribute] def nodeInEdges(self, node): # type: (Node) -> [Edge] """ Return the list of edges arriving to this node """ return [edge for edge in self.edges if edge.dst.node == node] def nodeOutEdges(self, node): # type: (Node) -> [Edge] """ Return the list of edges starting from this node """ return [edge for edge in self.edges if edge.src.node == node] @changeTopology def removeNode(self, nodeName): """ Remove the node identified by 'nodeName' from the graph. Returns: - a dictionary containing the incoming edges removed by this operation: {dstAttr.fullName, srcAttr.fullName} - a dictionary containing the outgoing edges removed by this operation: {dstAttr.fullName, srcAttr.fullName} - a dictionary containing the values, indices and keys of attributes that were connected to a ListAttribute prior to the removal of all edges: {dstAttr.fullName, (dstAttr.root.fullName, dstAttr.index, dstAttr.value)} """ node = self.node(nodeName) inEdges = {} outEdges = {} outListAttributes = {} # Remove all edges arriving to and starting from this node with GraphModification(self): # Two iterations over the outgoing edges are necessary: # - the first one is used to collect all the information about the edges while they are all there # (overall context) # - once we have collected all the information, the edges (and perhaps the entries in ListAttributes) can # actually be removed for edge in self.nodeOutEdges(node): outEdges[edge.dst.fullName] = edge.src.fullName if isinstance(edge.dst.root, ListAttribute): index = edge.dst.root.index(edge.dst) outListAttributes[edge.dst.fullName] = (edge.dst.root.fullName, index, edge.dst.value if edge.dst.value else None) for edge in self.nodeOutEdges(node): self.removeEdge(edge.dst) # Remove the corresponding attributes from the ListAttributes instead of just emptying their values if isinstance(edge.dst.root, ListAttribute): index = edge.dst.root.index(edge.dst) edge.dst.root.remove(index) for edge in self.nodeInEdges(node): self.removeEdge(edge.dst) inEdges[edge.dst.fullName] = edge.src.fullName node.alive = False self._nodes.remove(node) self.update() return inEdges, outEdges, outListAttributes def addNewNode( self, nodeType: str, name: Optional[str] = None, position: Optional[str] = None, **kwargs ) -> Node: """ Create and add a new node to the graph. Args: nodeType: the node type name. name: if specified, the desired name for this node. If not unique, will be prefixed (_N). position: the position of the node. **kwargs: keyword arguments to initialize the created node's attributes. Returns: The newly created node. """ if name and name in self._nodes.keys(): name = self._createUniqueNodeName(name) node = self.addNode(getNodeConstructor(nodeType, position=position, **kwargs), uniqueName=name) node.updateInternals() self._triggerNodeCreatedCallback([node]) return node def _triggerNodeCreatedCallback(self, nodes: Iterable[Node]): """ Trigger the `onNodeCreated` node descriptor callback for each node instance in `nodes`. """ with GraphModification(self): for node in nodes: if node.nodeDesc: node.nodeDesc.onNodeCreated(node) def _createUniqueNodeName(self, inputName: str, existingNames: Optional[set[str]] = None): """ Create a unique node name based on the input name. Args: inputName: The desired node name. existingNames: (optional) If specified, consider this set for uniqueness check, instead of the list of nodes. """ existingNodeNames = existingNames or set(self._nodes.objects.keys()) idx = 1 while idx: newName = f"{inputName}_{idx}" if newName not in existingNodeNames: return newName idx += 1 def node(self, nodeName) -> Optional[Node]: return self._nodes.get(nodeName) def upgradeNode(self, nodeName) -> Node: """ Upgrade the CompatibilityNode identified as 'nodeName' Args: nodeName (str): the name of the CompatibilityNode to upgrade Returns: - the upgraded (newly created) node - a dictionary containing the incoming edges removed by this operation: {dstAttr.fullName, srcAttr.fullName} - a dictionary containing the outgoing edges removed by this operation: {dstAttr.fullName, srcAttr.fullName} - a dictionary containing the values, indices and keys of attributes that were connected to a ListAttribute prior to the removal of all edges: {dstAttr.fullName, (dstAttr.root.fullName, dstAttr.index, dstAttr.value)} """ node = self.node(nodeName) if not isinstance(node, CompatibilityNode): raise ValueError("Upgrade is only available on CompatibilityNode instances.") upgradedNode = node.upgrade() self.replaceNode(nodeName, upgradedNode) return upgradedNode @changeTopology def replaceNode(self, nodeName: str, newNode: BaseNode): """ Replace the node idenfitied by `nodeName` with `newNode`, while restoring compatible edges. Args: nodeName: The name of the Node to replace. newNode: The Node instance to replace it with. """ with GraphModification(self): _, outEdges, outListAttributes = self.removeNode(nodeName) self.addNode(newNode, nodeName) self._restoreOutEdges(outEdges, outListAttributes) def _restoreOutEdges(self, outEdges: dict[str, str], outListAttributes): """ Restore output edges that were removed during a call to "removeNode". Args: outEdges: a dictionary containing the outgoing edges removed by a call to "removeNode". {dstAttr.fullName, srcAttr.fullName} outListAttributes: a dictionary containing the values, indices and keys of attributes that were connected to a ListAttribute prior to the removal of all edges. {dstAttr.fullName, (dstAttr.root.fullName, dstAttr.index, dstAttr.value)} """ def _recreateTargetListAttributeChildren(listAttrName: str, index: int, value: Any): listAttr = self.attribute(listAttrName) if not isinstance(listAttr, ListAttribute): return if isinstance(value, list): listAttr[index:index] = value else: listAttr.insert(index, value) for dstName, srcName in outEdges.items(): # Re-create the entries in ListAttributes that were completely removed during the call to "removeNode" if dstName in outListAttributes: _recreateTargetListAttributeChildren(*outListAttributes[dstName]) try: srcAttr = self.attribute(srcName) dstAttr = self.attribute(dstName) if srcAttr is None or dstAttr is None: logging.warning(f"Failed to restore edge {srcName}{' (missing)' if srcAttr is None else ''} -> {dstName}{' (missing)' if dstAttr is None else ''}") continue self.addEdge(srcAttr, dstAttr) except (KeyError, ValueError) as err: logging.warning(f"Failed to restore edge {srcName} -> {dstName}: {err}") def upgradeAllNodes(self): """ Upgrade all upgradable CompatibilityNode instances in the graph. """ nodeNames = [name for name, n in self._compatibilityNodes.items() if n.canUpgrade] with GraphModification(self): for nodeName in nodeNames: self.upgradeNode(nodeName) def reloadNodePlugins(self, nodeTypes: list[str]): """ Replace all the node instances of "nodeTypes" in the current graph with new node instances of the same type. If the description of the nodes has changed, the reloaded nodes will reflect theses changes. If "nodeTypes" is empty, then the function returns immediately. Args: nodeTypes: the list of node types that will be reloaded. """ if not nodeTypes: # No updated node to replace in the graph, nothing to do return newNodes: dict[str, BaseNode] = {} for node in self._nodes.values(): if node.nodeType in nodeTypes: newNode = nodeFactory(node.toDict(), node.nodeType, expectedUid=node._uid) newNodes[node.name] = newNode # Replace in a different loop to ensure all the nodes have been looped over: when looping # over self._nodes and replacing nodes at the same time, some nodes might not be reached for name, node in newNodes.items(): self.replaceNode(name, node) @Slot(str, result=Attribute) def attribute(self, fullName): # type: (str) -> Attribute """ Return the attribute identified by the unique name 'fullName'. If it does not exist, return None. """ node, attribute = fullName.split('.', 1) if self.node(node).hasAttribute(attribute): return self.node(node).attribute(attribute) return None @Slot(str, result=Attribute) def internalAttribute(self, fullName): # type: (str) -> Attribute """ Return the internal attribute identified by the unique name 'fullName'. If it does not exist, return None. """ node, attribute = fullName.split('.', 1) if self.node(node).hasInternalAttribute(attribute): return self.node(node).internalAttribute(attribute) return None @staticmethod def getNodeIndexFromName(name): """ Nodes are created with a suffix index; returns this index by parsing node name. Args: name (str): the node name Returns: int: the index retrieved from node name (-1 if not found) """ try: return int(name.split('_')[-1]) except Exception: return -1 @staticmethod def sortNodesByIndex(nodes): """ Sort the given list of Nodes using the suffix index in their names. [NodeName_1, NodeName_0] => [NodeName_0, NodeName_1] Args: nodes (list[Node]): the list of Nodes to sort Returns: list[Node]: the sorted list of Nodes based on their index """ return sorted(nodes, key=lambda x: Graph.getNodeIndexFromName(x.name)) def nodesOfType(self, nodeType, sortedByIndex=True): """ Returns all Nodes of the given nodeType. Args: nodeType (str): the node type name to consider. sortedByIndex (bool): whether to sort the nodes by their index (see Graph.sortNodesByIndex) Returns: list[Node]: the list of nodes matching the given nodeType. """ nodes = [n for n in self._nodes.values() if n.nodeType == nodeType] return self.sortNodesByIndex(nodes) if sortedByIndex else nodes def findInitNodes(self): """ Returns: list[Node]: the list of Init nodes (nodes inheriting from InitNode) """ nodes = [n for n in self._nodes.values() if isinstance(n.nodeDesc, meshroom.core.desc.InitNode)] return nodes def findNodeCandidates(self, nodeNameExpr: str) -> list[Node]: pattern = re.compile(nodeNameExpr) return [v for k, v in self._nodes.objects.items() if pattern.match(k)] def findNode(self, nodeExpr: str) -> Node: candidates = self.findNodeCandidates('^' + nodeExpr) if not candidates: raise KeyError(f'No node candidate for "{nodeExpr}"') if len(candidates) > 1: for c in candidates: if c.name == nodeExpr: return c raise KeyError(f'Multiple node candidates for "{nodeExpr}": {str([c.name for c in candidates])}') return candidates[0] def findNodes(self, nodesExpr): if isinstance(nodesExpr, list): return [self.findNode(nodeName) for nodeName in nodesExpr] return [self.findNode(nodesExpr)] def edge(self, dstAttributeName): return self._edges.get(dstAttributeName) def getLeafNodes(self, dependenciesOnly): nodesWithOutputLink = {edge.src.node for edge in self.getEdges(dependenciesOnly)} return set(self._nodes) - nodesWithOutputLink def getRootNodes(self, dependenciesOnly): nodesWithInputLink = {edge.dst.node for edge in self.getEdges(dependenciesOnly)} return set(self._nodes) - nodesWithInputLink @changeTopology def addEdge(self, srcAttr: Attribute, dstAttr: Attribute) -> tuple[list[Attribute], list[Attribute]]: if not srcAttr.node.graph == dstAttr.node.graph == self: raise InvalidEdgeError(srcAttr.fullName, dstAttr.fullName, "Attributes do not belong to this graph.") if not dstAttr.validateIncomingConnection(srcAttr): raise InvalidEdgeError(srcAttr.fullName, dstAttr.fullName, f"Attributes are not compatible (src base type: {srcAttr.baseType}; dst base type: {dstAttr.baseType}).") deletedEdge = [] if dstAttr in self.edges.keys(): deletedEdge = self.removeEdge(dstAttr) edge = Edge(srcAttr, dstAttr) self.edges.add(edge) self.markNodesDirty(dstAttr.node) dstAttr.valueChanged.emit() dstAttr.inputLinksChanged.emit() srcAttr.outputLinksChanged.emit() return [edge.src, edge.dst], deletedEdge @changeTopology def removeEdge(self, dstAttr: Attribute): if not self.edges.get(dstAttr): return None edge = self.edges.pop(dstAttr) self.markNodesDirty(dstAttr.node) dstAttr.valueChanged.emit() dstAttr.inputLinksChanged.emit() edge.src.outputLinksChanged.emit() return [edge.src, dstAttr] def getDepth(self, node, minimal=False): """ Return node's depth in this Graph. By default, returns the maximal depth of the node unless minimal is set to True. Args: node (Node): the node to consider. minimal (bool): whether to return the minimal depth instead of the maximal one (default). Returns: int: the node's depth in this Graph. """ assert node.graph == self assert not self.dirtyTopology minDepth, maxDepth = self._nodesMinMaxDepths[node] return minDepth if minimal else maxDepth def getInputEdges(self, node, dependenciesOnly): return {edge for edge in self.getEdges(dependenciesOnly=dependenciesOnly) if edge.dst.node is node} def _getInputEdgesPerNode(self, dependenciesOnly): nodeEdges = defaultdict(set) for edge in self.getEdges(dependenciesOnly=dependenciesOnly): nodeEdges[edge.dst.node].add(edge.src.node) return nodeEdges def _getOutputEdgesPerNode(self, dependenciesOnly): nodeEdges = defaultdict(set) for edge in self.getEdges(dependenciesOnly=dependenciesOnly): nodeEdges[edge.src.node].add(edge.dst.node) return nodeEdges def dfs(self, visitor, startNodes=None, longestPathFirst=False): # Default direction (visitor.reverse=False): from node to root # Reverse direction (visitor.reverse=True): from node to leaves nodeChildren = self._getOutputEdgesPerNode(visitor.dependenciesOnly) \ if visitor.reverse else self._getInputEdgesPerNode(visitor.dependenciesOnly) # Initialize color map colors = {} for u in self._nodes: colors[u] = WHITE if longestPathFirst and visitor.reverse: # Because we have no knowledge of the node's count between a node and its leaves, # it is not possible to handle this case at the moment raise NotImplementedError("Graph.dfs(): longestPathFirst=True and visitor.reverse=True are not " "compatible yet.") nodes = startNodes or (self.getRootNodes(visitor.dependenciesOnly) if visitor.reverse else self.getLeafNodes(visitor.dependenciesOnly)) if longestPathFirst: # Graph topology must be known and node depths up-to-date assert not self.dirtyTopology nodes = sorted(nodes, key=lambda item: item.depth) try: for node in nodes: self.dfsVisit(node, visitor, colors, nodeChildren, longestPathFirst) except StopGraphVisit: pass def dfsVisit(self, u, visitor, colors, nodeChildren, longestPathFirst): try: self._dfsVisit(u, visitor, colors, nodeChildren, longestPathFirst) except StopBranchVisit: pass def _dfsVisit(self, u, visitor, colors, nodeChildren, longestPathFirst): colors[u] = GRAY visitor.discoverVertex(u, self) # d_time[u] = time = time + 1 children = nodeChildren[u] if longestPathFirst: assert not self.dirtyTopology children = sorted(children, reverse=True, key=lambda item: self._nodesMinMaxDepths[item][1]) for v in children: visitor.examineEdge((u, v), self) if colors[v] == WHITE: visitor.treeEdge((u, v), self) # (u,v) is a tree edge self.dfsVisit(v, visitor, colors, nodeChildren, longestPathFirst) # TODO: avoid recursion elif colors[v] == GRAY: # (u,v) is a back edge visitor.backEdge((u, v), self) elif colors[v] == BLACK: # (u,v) is a cross or forward edge visitor.forwardOrCrossEdge((u, v), self) visitor.finishEdge((u, v), self) colors[u] = BLACK visitor.finishVertex(u, self) def dfsOnFinish(self, startNodes=None, longestPathFirst=False, reverse=False, dependenciesOnly=False): """ Return the node chain from startNodes to the graph roots/leaves. Order is defined by the visit and finishVertex event. Args: startNodes (Node list): the nodes to start the visit from. longestPathFirst (bool): (optional) if multiple paths, nodes belonging to the longest one will be visited first. reverse (bool): (optional) direction of visit. True is for getting nodes depending on the startNodes (to leaves). False is for getting nodes required for the startNodes (to roots). Returns: The list of nodes and edges, from startNodes to the graph roots/leaves following edges. """ nodes = [] edges = [] visitor = Visitor(reverse=reverse, dependenciesOnly=dependenciesOnly) visitor.finishVertex = lambda vertex, graph: nodes.append(vertex) visitor.finishEdge = lambda edge, graph: edges.append(edge) self.dfs(visitor=visitor, startNodes=startNodes, longestPathFirst=longestPathFirst) return nodes, edges def dfsOnDiscover(self, startNodes=None, filterTypes=None, longestPathFirst=False, reverse=False, dependenciesOnly=False): """ Return the node chain from startNodes to the graph roots/leaves. Order is defined by the visit and discoverVertex event. Args: startNodes (Node list): the nodes to start the visit from. filterTypes (str list): (optional) only return the nodes of the given types (does not stop the visit, this is a post-process only) longestPathFirst (bool): (optional) if multiple paths, nodes belonging to the longest one will be visited first. reverse (bool): (optional) direction of visit. True is for getting nodes depending on the startNodes (to leaves). False is for getting nodes required for the startNodes (to roots). Returns: The list of nodes and edges, from startNodes to the graph roots/leaves following edges. """ nodes = [] edges = [] visitor = Visitor(reverse=reverse, dependenciesOnly=dependenciesOnly) def discoverVertex(vertex, graph): if not filterTypes or vertex.nodeType in filterTypes: nodes.append(vertex) visitor.discoverVertex = discoverVertex visitor.examineEdge = lambda edge, graph: edges.append(edge) self.dfs(visitor=visitor, startNodes=startNodes, longestPathFirst=longestPathFirst) return nodes, edges def dfsToProcess(self, startNodes=None): """ Return the full list of predecessor nodes to process in order to compute the given nodes. Args: startNodes: list of starting nodes. Use all leaves if empty. Returns: visited nodes and edges that are not already computed (node.status != SUCCESS). The order is defined by the visit and finishVertex event. """ nodes = [] edges = [] visitor = Visitor(reverse=False, dependenciesOnly=True) def discoverVertex(vertex, graph): if vertex.hasStatus(Status.SUCCESS): # stop branch visit if discovering a node already computed raise StopBranchVisit() def finishVertex(vertex, graph): if not vertex.chunks: # Chunks have not been initialized nodes.append(vertex) return chunksToProcess = [] for chunk in vertex.chunks: if chunk.status.status is not Status.SUCCESS: chunksToProcess.append(chunk) if chunksToProcess: nodes.append(vertex) # We could collect specific chunks def finishEdge(edge, graph): if edge[0].isComputed or edge[1].isComputed: return edges.append(edge) visitor.finishVertex = finishVertex visitor.finishEdge = finishEdge visitor.discoverVertex = discoverVertex self.dfs(visitor=visitor, startNodes=startNodes) return nodes, edges @Slot(Node, result=bool) def canComputeTopologically(self, node): """ Return the computability of a node based on itself and its dependency chain. It is a static result as it depends on the graph topology. Computation cannot happen for: - CompatibilityNodes - nodes having a non-computed CompatibilityNode in its dependency chain Args: node (Node): the node to evaluate Returns: bool: whether the node can be computed """ if isinstance(node, CompatibilityNode): return False return not self._computationBlocked[node] def updateNodesTopologicalData(self): """ Compute and cache nodes topological data: - min and max depth - computability """ self._nodesMinMaxDepths.clear() self._computationBlocked.clear() compatNodes = [] visitor = Visitor(reverse=False, dependenciesOnly=False) def discoverVertex(vertex, graph): # initialize depths self._nodesMinMaxDepths[vertex] = (0, 0) # initialize computability self._computationBlocked[vertex] = False if isinstance(vertex, CompatibilityNode): compatNodes.append(vertex) # a not computed CompatibilityNode blocks computation if not vertex.hasStatus(Status.SUCCESS): self._computationBlocked[vertex] = True def finishEdge(edge, graph): currentVertex, inputVertex = edge # update depths currentDepths = self._nodesMinMaxDepths[currentVertex] inputDepths = self._nodesMinMaxDepths[inputVertex] if currentDepths[0] == 0: # if not initialized, set the depth of the first child depthMin = inputDepths[0] + 1 else: depthMin = min(currentDepths[0], inputDepths[0] + 1) self._nodesMinMaxDepths[currentVertex] = (depthMin, max(currentDepths[1], inputDepths[1] + 1)) # update computability if currentVertex.hasStatus(Status.SUCCESS): # output is already computed and available, # does not depend on input connections computability return # propagate inputVertex computability self._computationBlocked[currentVertex] |= self._computationBlocked[inputVertex] leaves = self.getLeafNodes(visitor.dependenciesOnly) visitor.finishEdge = finishEdge visitor.discoverVertex = discoverVertex self.dfs(visitor=visitor, startNodes=leaves) # update graph computability status canComputeLeaves = all([self.canComputeTopologically(node) for node in leaves]) if self._canComputeLeaves != canComputeLeaves: self._canComputeLeaves = canComputeLeaves self.canComputeLeavesChanged.emit() # update compatibilityNodes model if len(self._compatibilityNodes) != len(compatNodes): self._compatibilityNodes.reset(compatNodes) compatibilityNodes = Property(BaseObject, lambda self: self._compatibilityNodes, constant=True) def dfsMaxEdgeLength(self, startNodes=None, dependenciesOnly=True): """ :param startNodes: list of starting nodes. Use all leaves if empty. :return: """ nodesStack = [] edgesScore = defaultdict(int) visitor = Visitor(reverse=False, dependenciesOnly=dependenciesOnly) def finishEdge(edge, graph): u, v = edge for i, n in enumerate(reversed(nodesStack)): index = i + 1 if index > edgesScore[(n, v)]: edgesScore[(n, v)] = index def finishVertex(vertex, graph): v = nodesStack.pop() assert v == vertex visitor.discoverVertex = lambda vertex, graph: nodesStack.append(vertex) visitor.finishVertex = finishVertex visitor.finishEdge = finishEdge self.dfs(visitor=visitor, startNodes=startNodes, longestPathFirst=True) return edgesScore def flowEdges(self, startNodes=None, dependenciesOnly=True): """ Return as few edges as possible, such that if there is a directed path from one vertex to another in the original graph, there is also such a path in the reduction. :param startNodes: :return: the remaining edges after a transitive reduction of the graph. """ flowEdges = [] edgesScore = self.dfsMaxEdgeLength(startNodes, dependenciesOnly) for link, score in edgesScore.items(): assert score != 0 if score == 1: flowEdges.append(link) return flowEdges def getEdges(self, dependenciesOnly=False): if not dependenciesOnly: return self.edges outEdges = [] for e in self.edges: attr = e.src if dependenciesOnly: if attr.isLink: attr = attr.inputRootLink if not attr.isOutput: continue newE = Edge(attr, e.dst) outEdges.append(newE) return outEdges def getInputNodes(self, node, recursive, dependenciesOnly): """ Return either the first level input nodes of a node or the whole chain. """ if not recursive: return {edge.src.node for edge in self.getEdges(dependenciesOnly) if edge.dst.node is node} inputNodes, edges = self.dfsOnDiscover(startNodes=[node], filterTypes=None, reverse=False) return inputNodes[1:] # exclude current node def getOutputNodes(self, node, recursive, dependenciesOnly): """ Return either the first level output nodes of a node or the whole chain. """ if not recursive: return {edge.dst.node for edge in self.getEdges(dependenciesOnly) if edge.src.node is node} outputNodes, edges = self.dfsOnDiscover(startNodes=[node], filterTypes=None, reverse=True) return outputNodes[1:] # exclude current node @Slot(Node, result=int) def canSubmitOrCompute(self, startNode): """ Check if a node can be submitted/computed. It does not depend on the topology of the graph and is based on the node status and its dependencies. Returns: int: 0 = cannot be submitted or computed / 1 = can be computed / 2 = can be submitted / 3 = can be submitted and computed """ if startNode.isAlreadySubmittedOrFinished(): return 0 class SCVisitor(Visitor): def __init__(self, reverse, dependenciesOnly): super().__init__(reverse, dependenciesOnly) canCompute = True canSubmit = True def discoverVertex(self, vertex, graph): if vertex.isAlreadySubmitted(): self.canSubmit = False if vertex.isExtern(): self.canCompute = False visitor = SCVisitor(reverse=False, dependenciesOnly=True) self.dfs(visitor=visitor, startNodes=[startNode]) return visitor.canCompute + (2 * visitor.canSubmit) def _applyExpr(self): with GraphModification(self): for node in self._nodes: node._applyExpr() def toDict(self): nodes = {k: node.toDict() for k, node in self._nodes.objects.items()} nodes = dict(sorted(nodes.items())) return nodes @Slot(result=str) def asString(self): return str(self.toDict()) def copy(self) -> "Graph": """ Create a copy of this Graph instance. """ graph = Graph("") graph._deserialize(self.serialize()) return graph def serialize(self, asTemplate: bool = False) -> dict: """ Serialize this Graph instance. Args: asTemplate: Whether to use the template serialization. Returns: The serialized graph data. """ SerializerClass = TemplateGraphSerializer if asTemplate else GraphSerializer return SerializerClass(self).serialize() def serializePartial(self, nodes: list[Node]) -> dict: """ Partially serialize this graph considering only the given list of `nodes`. Args: nodes: The list of nodes to serialize. Returns: The serialized graph data. """ return PartialGraphSerializer(self, nodes=nodes).serialize() def save(self, filepath=None, setupProjectFile=True, template=False): """ Save the current Meshroom graph as a serialized ".mg" file. Args: filepath: project filepath to save as. setupProjectFile: Store the reference to the project file and setup the cache directory. If false, it only saves the graph of the project file as a template. template: If true, saves the current graph as a template. """ # Update the saving flag indicating that the current graph is being saved self._saving = True try: self._save(filepath=filepath, setupProjectFile=setupProjectFile, template=template) finally: self._saving = False def _generateNextPath(self): """ Generate the filename for the next version - scene.mg -> scene1.mg - scene1.mg -> scene2.mg - scene_001.mg -> scene_002.mg (preserves zero-padding) - scene1.mg and scene2.mg exists -> scene3.mg """ path = Path(self._filepath) stem, ext = path.stem, path.suffix # Match name and version number at the end versionMatch = re.match(r'^(.+?)(\d+)$', stem) if versionMatch: stemBase, versionStr = versionMatch.group(1), versionMatch.group(2) version = int(versionStr) + 1 # Preserve zero-padding from original padding = len(versionStr) else: stemBase, version, padding = stem, 1, 1 # Find an available name while True: # Format version number with appropriate padding versionStr = str(version).zfill(padding) pathCandidate = path.parent / f"{stemBase}{versionStr}{ext}" if not pathCandidate.exists(): return str(pathCandidate) version += 1 def saveAsNewVersion(self): """ Increase the version of the file and save """ # Generate the new version path path = self._generateNextPath() # Update the saving flag indicating that the current graph is being saved self._saving = True try: self._save(filepath=path) finally: self._saving = False def _save(self, filepath=None, setupProjectFile=True, template=False): path = filepath or self._filepath if not path: path = generateTempProjectFilepath() data = self.serialize(template) with open(path, 'w') as jsonFile: json.dump(data, jsonFile, indent=4) if path != self._filepath and setupProjectFile: self._setFilepath(path) # update the file date version self._fileDateVersion = os.path.getmtime(path) def saveAsTemp(self, tmpFolder=None): """ Save the current Meshroom graph as a temporary project file. """ # Update the saving flag indicating that the current graph is being saved self._saving = True try: self._saveAsTemp(tmpFolder) finally: self._saving = False def _saveAsTemp(self, tmpFolder=None): projectPath = generateTempProjectFilepath(tmpFolder) self._save(projectPath) def _setFilepath(self, filepath): """ Set the internal filepath of this Graph. This method should not be used directly from outside, use save/load instead. Args: filepath: the graph file path """ if not os.path.isfile(filepath): self._unsetFilepath() return # Make sure the path is stored using the POSIX convention # so that it can be used when creating sub-processes for node execution. newFilepath = Path(filepath).as_posix() if self._filepath == newFilepath: return self._filepath = newFilepath # For now: # * cache folder is located next to the graph file # * graph name if the basename of the graph file self.name = os.path.splitext(os.path.basename(filepath))[0] self.cacheDir = os.path.join(os.path.abspath(os.path.dirname(filepath)), meshroom.core.cacheFolderName) self.filepathChanged.emit() def _unsetFilepath(self): self._filepath = "" self.name = "" self.cacheDir = "" self.filepathChanged.emit() def updateInternals(self, startNodes=None, force=False): nodes, edges = self.dfsOnFinish(startNodes=startNodes) for node in nodes: if node.dirty or force: node.updateInternals() def updateStatusFromCache(self, force=False): for node in self._nodes: if node.dirty or force: node.updateStatusFromCache() def updateStatisticsFromCache(self): for node in self._nodes: node.updateStatisticsFromCache() def updateNodesPerUid(self): """ Update the duplicate nodes (sharing same UID) list of each node. """ # First step is to construct a map UID/nodes nodesPerUid = {} for node in self.nodes: uid = node._uid # We try to add the node to the list corresponding to this UID try: nodesPerUid.get(uid).append(node) # If it fails because the uid is not in the map, we add it except AttributeError: nodesPerUid.update({uid: [node]}) # Now, update each individual node for node in self.nodes: node.updateDuplicates(nodesPerUid) def updateJobManagerWithNode(self, node): if node._uid in jobManager._nodeToJob.keys(): return jobInfo = node._nodeStatus.jobInfo if not jobInfo: return jid, subName = jobInfo.get("jid"), jobInfo.get("submitterName") for _subName, sub in submitters.items(): if _subName == subName: try: job = sub.retrieveJob(int(jid)) jobManager.addJob(job, [node]) break except Exception as e: logging.warning(f"Failed to retrieve job {jid} from submitter {subName} : {e}") break def update(self): if not self._updateEnabled: # To do the update once for multiple changes self._updateRequested = True return self.updateInternals() if os.path.exists(self._cacheDir): self.updateStatusFromCache() for node in self.nodes: node.dirty = False self.updateJobManagerWithNode(node) self.updateNodesPerUid() # Graph topology has changed if self.dirtyTopology: # update nodes topological data cache self.updateNodesTopologicalData() self.dirtyTopology = False self.updated.emit() def updateMonitoredFiles(self): self.statusUpdated.emit() def markNodesDirty(self, fromNode): """ Mark all nodes following 'fromNode' as dirty. All nodes marked as dirty will get their outputs to be re-evaluated during the next graph update. Args: fromNode (Node): the node to start the invalidation from See Also: Graph.update, Graph.updateInternals, Graph.updateStatusFromCache """ nodes, edges = self.dfsOnDiscover(startNodes=[fromNode], reverse=True) for node in nodes: node.dirty = True def stopExecution(self): """ Request graph execution to be stopped by terminating running chunks""" for node in self.nodes: if node.canBeStopped(): for chunk in node.chunks: chunk.stopProcess() elif node.canBeCanceled(): node.clearSubmittedChunks() @Slot() @Slot(list) def forceUnlockNodes(self, nodes=None): """ Force to unlock all the nodes. """ nodes = nodes if nodes else self.nodes for node in nodes: node.setLocked(False) @Slot() @Slot(list) def clearSubmittedNodes(self, nodes=None): """ Reset the status of already submitted nodes to Status.NONE """ nodes = nodes if nodes else self.nodes for node in nodes: node.clearSubmittedChunks() def clearLocallySubmittedNodes(self): """ Reset the status of already locally submitted nodes to Status.NONE """ for node in self.nodes: node.clearLocallySubmittedChunks() def getChunksByStatus(self, status): """ Return the list of NodeChunks with the given status. """ chunks = [] for node in self.nodes: chunks += [chunk for chunk in node.chunks if chunk.status.status == status] return chunks def getChunks(self, nodes=None): """ Returns the list of NodeChunks for the given list of nodes (for all nodes if nodes is None). """ chunks = [] for node in nodes or self.nodes: chunks += [chunk for chunk in node.chunks] return chunks def getOrderedChunks(self): """ Get chunks as visited by dfsOnFinish. Returns: list of NodeChunks: the ordered list of NodeChunks """ return self.getChunks(self.dfsOnFinish()[0]) @property def nodes(self): return self._nodes @property def edges(self): return self._edges @property def cacheDir(self): return self._cacheDir @cacheDir.setter def cacheDir(self, value): if self._cacheDir == value: return # use unix-style paths for cache directory self._cacheDir = value.replace(os.path.sep, "/") self.updateInternals(force=True) self.updateStatusFromCache(force=True) self.cacheDirChanged.emit() @property def fileDateVersion(self): return self._fileDateVersion @fileDateVersion.setter def fileDateVersion(self, value): self._fileDateVersion = value @Slot(str, result=float) def getFileDateVersionFromPath(self, value): return os.path.getmtime(value) def setVerbose(self, v): with GraphModification(self): for node in self._nodes: if node.hasAttribute('verbose'): try: node.verbose.value = v except Exception: pass nodes = Property(BaseObject, nodes.fget, constant=True) edges = Property(BaseObject, edges.fget, constant=True) filepathChanged = Signal() filepath = Property(str, lambda self: self._filepath, notify=filepathChanged) isSaving = Property(bool, isSaving.fget, constant=True) fileReleaseVersion = Property(str, lambda self: self.header.get(GraphIO.Keys.ReleaseVersion, "0.0"), notify=filepathChanged) fileDateVersion = Property(float, fileDateVersion.fget, fileDateVersion.fset, notify=filepathChanged) cacheDirChanged = Signal() cacheDir = Property(str, cacheDir.fget, cacheDir.fset, notify=cacheDirChanged) updated = Signal() statusUpdated = Signal() canComputeLeavesChanged = Signal() canComputeLeaves = Property(bool, lambda self: self._canComputeLeaves, notify=canComputeLeavesChanged) def loadGraph(filepath, strictCompatibility: bool = False) -> Graph: """ Load a Graph from a Meshroom Graph (.mg) file. Args: filepath: The path to the Meshroom Graph file. strictCompatibility: If True, raise a GraphCompatibilityError if the loaded Graph has node compatibility issues. Returns: Graph: The loaded Graph instance. Raises: GraphCompatibilityError: If the Graph has node compatibility issues and `strictCompatibility` is True. """ graph = Graph("") graph.load(filepath) compatibilityIssues = len(graph.compatibilityNodes) > 0 if compatibilityIssues and strictCompatibility: raise GraphCompatibilityError(filepath, {n.name: str(n.issue) for n in graph.compatibilityNodes}) graph.update() return graph def getAlreadySubmittedChunks(nodes): out = [] for node in nodes: for chunk in node.chunks: if chunk.isAlreadySubmitted(): out.append(chunk) return out def executeGraph(graph, toNodes=None, forceCompute=False, forceStatus=False): """ """ if forceCompute: nodes, edges = graph.dfsOnFinish(startNodes=toNodes) else: nodes, edges = graph.dfsToProcess(startNodes=toNodes) chunksInConflict = getAlreadySubmittedChunks(nodes) if chunksInConflict: chunksStatus = {chunk.status.status.name for chunk in chunksInConflict} chunksName = [node.name for node in chunksInConflict] msg = "WARNING: Some nodes are already submitted with status: {}\nNodes: {}".format( ", ".join(chunksStatus), ", ".join(chunksName) ) if forceStatus: print(msg) else: raise RuntimeError(msg) print("Nodes to execute: ", str([n.name for n in nodes])) graph.save() for node in nodes: node.initStatusOnCompute(forceCompute) for n, node in enumerate(nodes): try: # If the node is in compatibility mode, it cannot be computed if node.isCompatibilityNode: logging.warning(f"{node.name} is in Compatibility Mode and cannot be computed: {node.issueDetails}.") continue node.preprocess() if not node._chunksCreated: node.createChunks() multiChunks = len(node.chunks) > 1 for c, chunk in enumerate(node.chunks): if multiChunks: print('\n[{node}/{nbNodes}]({chunk}/{nbChunks}) {nodeName}'.format( node=n+1, nbNodes=len(nodes), chunk=c+1, nbChunks=len(node.chunks), nodeName=node.nodeType)) else: print(f'\n[{n + 1}/{len(nodes)}] {node.nodeType}') chunk.process(forceCompute) node.postprocess() except Exception as exc: logging.error(f"Error on node computation: {exc}") graph.clearSubmittedNodes() raise for node in nodes: node.endSequence() def submitGraph(graph, submitter, toNodes=None, submitLabel="{projectName}"): nodesToProcess, edgesToProcess = graph.dfsToProcess(startNodes=toNodes) flowEdges = graph.flowEdges(startNodes=toNodes) edgesToProcess = set(edgesToProcess).intersection(flowEdges) if not nodesToProcess: logging.warning('Nothing to compute') return logging.info(f"Nodes to process: {edgesToProcess}") logging.info(f"Edges to process: {edgesToProcess}") sub = None if submitter: sub = meshroom.core.submitters.get(submitter, None) elif len(meshroom.core.submitters) == 1: # if only one submitter available use it sub = meshroom.core.submitters.values()[0] if sub is None: raise RuntimeError("Unknown Submitter: '{submitter}'. Available submitters are: '{allSubmitters}'.".format( submitter=submitter, allSubmitters=str(meshroom.core.submitters.keys()))) for node in nodesToProcess: node.initStatusOnSubmit() jobManager.resetNodeJob(node) try: res = sub.submit(nodesToProcess, edgesToProcess, graph.filepath, submitLabel=submitLabel) if res: if isinstance(res, BaseSubmittedJob): jobManager.addJob(res, nodesToProcess) else: for node in nodesToProcess: # TODO : Notify the node that there was an issue on submit pass except Exception as exc: logging.error(f"Error on submit: {exc}") def submit(graphFile, submitter, toNode=None, submitLabel="{projectName}"): """ Submit the given graph via the given submitter. """ graph = loadGraph(graphFile) toNodes = graph.findNodes(toNode) if toNode else None submitGraph(graph, submitter, toNodes, submitLabel=submitLabel) ================================================ FILE: meshroom/core/graphIO.py ================================================ from enum import Enum from typing import Any, TYPE_CHECKING, Union import meshroom from meshroom.core import Version from meshroom.core.attribute import Attribute, GroupAttribute, ListAttribute from meshroom.core.node import Node if TYPE_CHECKING: from meshroom.core.graph import Graph class GraphIO: """Centralize Graph file keys and IO version.""" __version__ = "2.0" class Keys: """File Keys.""" # Doesn't inherit enum to simplify usage (GraphIO.Keys.XX, without .value) Header = "header" NodesVersions = "nodesVersions" ReleaseVersion = "releaseVersion" FileVersion = "fileVersion" Graph = "graph" Template = "template" class Features(Enum): """File Features.""" Graph = "graph" Header = "header" NodesVersions = "nodesVersions" PrecomputedOutputs = "precomputedOutputs" NodesPositions = "nodesPositions" @staticmethod def getFeaturesForVersion(fileVersion: Union[str, Version]) -> tuple["GraphIO.Features", ...]: """Return the list of supported features based on a file version. Args: fileVersion (str, Version): the file version Returns: tuple of GraphIO.Features: the list of supported features """ if isinstance(fileVersion, str): fileVersion = Version(fileVersion) features = [GraphIO.Features.Graph] if fileVersion >= Version("1.0"): features += [ GraphIO.Features.Header, GraphIO.Features.NodesVersions, GraphIO.Features.PrecomputedOutputs, ] if fileVersion >= Version("1.1"): features += [GraphIO.Features.NodesPositions] return tuple(features) class GraphSerializer: """Standard Graph serializer.""" def __init__(self, graph: "Graph") -> None: self._graph = graph def serialize(self) -> dict: """ Serialize the Graph. """ return { GraphIO.Keys.Header: self.serializeHeader(), GraphIO.Keys.Graph: self.serializeContent(), } @property def nodes(self) -> list[Node]: return self._graph.nodes def serializeHeader(self) -> dict: """Build and return the graph serialization header. The header contains metadata about the graph, such as the: - version of the software used to create it. - version of the file format. - version of the nodes types used in the graph. - template flag. """ header: dict[str, Any] = {} header[GraphIO.Keys.ReleaseVersion] = meshroom.__version__ header[GraphIO.Keys.FileVersion] = GraphIO.__version__ header[GraphIO.Keys.NodesVersions] = self._getNodeTypesVersions() return header def _getNodeTypesVersions(self) -> dict[str, str]: """Get registered versions of each node types in `nodes`, excluding CompatibilityNode instances.""" nodeTypes = {node.nodeDesc.__class__ for node in self.nodes if isinstance(node, Node)} nodeTypesVersions = { nodeType.__name__: version for nodeType in nodeTypes if (version := meshroom.core.nodeVersion(nodeType)) is not None } # Sort them by name (to avoid random order changing from one save to another). return dict(sorted(nodeTypesVersions.items())) def serializeContent(self) -> dict: """Graph content serialization logic.""" return {node.name: self.serializeNode(node) for node in sorted(self.nodes, key=lambda n: n.name)} def serializeNode(self, node: Node) -> dict: """Node serialization logic.""" return node.toDict() class TemplateGraphSerializer(GraphSerializer): """Serializer for serializing a graph as a template.""" def serializeHeader(self) -> dict: header = super().serializeHeader() header[GraphIO.Keys.Template] = True return header def serializeNode(self, node: Node) -> dict: """Adapt node serialization to template graphs. Instead of getting all the inputs and internal attribute keys, only get the keys of the attributes whose value is not the default one. The output attributes, UIDs, parallelization parameters and internal folder are not relevant for templates, so they are explicitly removed from the returned dictionary. """ # For now, implemented as a post-process to update the default serialization. nodeData = super().serializeNode(node) inputKeys = list(nodeData["inputs"].keys()) internalInputKeys = [] internalInputs = nodeData.get("internalInputs", None) if internalInputs: internalInputKeys = list(internalInputs.keys()) for attrName in inputKeys: attribute = node.attribute(attrName) # check that attribute is not a link for choice attributes if attribute.isDefault and not attribute.isLink: del nodeData["inputs"][attrName] for attrName in internalInputKeys: attribute = node.internalAttribute(attrName) # check that internal attribute is not a link for choice attributes if attribute.isDefault and not attribute.isLink: del nodeData["internalInputs"][attrName] # If all the internal attributes are set to their default values, remove the entry if len(nodeData["internalInputs"]) == 0: del nodeData["internalInputs"] del nodeData["outputs"] del nodeData["uid"] del nodeData["parallelization"] return nodeData class PartialGraphSerializer(GraphSerializer): """Serializer to serialize a partial graph (a subset of nodes).""" def __init__(self, graph: "Graph", nodes: list[Node]): super().__init__(graph) self._nodes = nodes @property def nodes(self) -> list[Node]: """Override to consider only the subset of nodes.""" return self._nodes def serializeNode(self, node: Node) -> dict: """Adapt node serialization to partial graph serialization.""" # NOTE: For now, implemented as a post-process to the default serialization. nodeData = super().serializeNode(node) # Override input attributes with custom serialization logic, to handle attributes # connected to nodes that are not in the list of nodes to serialize. if nodeData.get("inputs", None): for attributeName in nodeData["inputs"]: nodeData["inputs"][attributeName] = self._serializeAttribute(node.attribute(attributeName)) # Clear UID for non-compatibility nodes, as the custom attribute serialization # can be impacting the UID by removing connections to missing nodes. if not node.isCompatibilityNode: del nodeData["uid"] return nodeData def _serializeAttribute(self, attribute: Attribute) -> Any: """ Serialize `attribute` (recursively for list/groups) and deal with attributes being connected to nodes that are not part of the partial list of nodes to serialize. """ linkAttribute = attribute.inputLink if linkAttribute is not None: # Use standard link serialization if upstream node is part of the serialization. if linkAttribute.node in self.nodes: return attribute.getSerializedValue() # Skip link serialization otherwise. # If part of a list, this entry can be discarded. if isinstance(attribute.root, ListAttribute): return None # Otherwise, return the default value for this attribute. return attribute.getDefaultValue() if isinstance(attribute, ListAttribute): # Recusively serialize each child of the ListAttribute, skipping those for which the attribute # serialization logic above returns None. return [ exportValue for child in attribute if (exportValue := self._serializeAttribute(child)) is not None ] if isinstance(attribute, GroupAttribute): # Recursively serialize each child of the group attribute. return {name: self._serializeAttribute(child) for name, child in attribute.value.items()} return attribute.getSerializedValue() ================================================ FILE: meshroom/core/keyValues.py ================================================ import json from typing import Any from meshroom.common import BaseObject, Property, Variant, Signal, DictModel, Slot from meshroom.core import desc, hashValue class KeyValues(BaseObject): """ Used to store a list of pairs (key, value) based on an attribute description. """ class KeyValuePair(BaseObject): """ Pair of (key, value), this object cannot be modified. """ def __init__(self, key: int, value: Any, parent=None): super().__init__(parent) self._key = key self._value = value key = Property(int, lambda self: self._key, constant=True) value = Property(Variant, lambda self: self._value, constant=True) def __init__(self, desc: desc.Attribute, parent=None): """ KeyValues constructor Args: description: The corresponding Attribute description. parent: (optional) The parent BaseObject if any. """ super().__init__(parent) self._desc = desc self._pairs = DictModel(keyAttrName="key", parent=self) # TODO: Add interpolation. For now no interpolation. def reset(self): """ Clear the list of pairs. """ self._pairs.clear() self.pairsChanged.emit() def resetFromDict(self, pairs: dict): """ Reset the list of pairs from a given dict. """ self._pairs.clear() for k, v in pairs.items(): self._pairs.add(KeyValues.KeyValuePair(int(k), self._desc.validateValue(v), self)) self.pairsChanged.emit() def add(self, key: str, value: Any): """ Add a new pair (key, value) to the list of pairs from a given key and value. """ # Avoid negative key if int(key) < 0: return # Get existing pair with the given key (or None) pair = self._pairs.get(int(key)) # Remove existing pair if pair is not None: self._pairs.remove(pair) # Add new pair self._pairs.add(KeyValues.KeyValuePair(int(key), self._desc.validateValue(value), self)) self.pairsChanged.emit() def remove(self, key: str): """ Remove a pair (key, value) of the list of pairs from a given key. """ # Get existing pair with the given key (or None) pair = self._pairs.get(int(key)) # Remove existing pair if pair is not None: self._pairs.remove(pair) self.pairsChanged.emit() def getSerializedValues(self) -> Any: """ Return the list of pairs serialized. """ return { str(pair.key): pair.value for pair in self._pairs } def getKeys(self) -> list: """ Return the list of keys. """ return [ str(pair.key) for pair in self._pairs ] def getJson(self) -> str: """ Return the list of pairs formatted as a JSON string. """ return json.dumps(self.getSerializedValues()) def uid(self) -> str: """ Compute the UID from the list of pairs. """ uids = [] for pair in sorted(self._pairs, key=lambda pair: pair.key): uids.extend([pair.key, pair.value]) return hashValue(uids) @Slot(str, result=bool) def hasKey(self, key: str) -> bool: """ Whether this given key exists in the list of pairs. """ return self._pairs.get(int(key)) is not None @Slot(str, result=Variant) def getValueAtKeyOrDefault(self, key: str) -> Any: """ Return the value or the default value from a given key. """ # Get existing pair with the given key (or None) pair = self._pairs.get(int(key)) # Return pair value if pair is not None: return pair.value # Return default value return self._desc.value # Emitted when something changed in the list of pairs. pairsChanged = Signal() # The list of pairs (key, value). pairs = Property(Variant, lambda self: self._pairs, notify=pairsChanged) # The type of key used (viewId, poseId, ...). keyType = Property(str, lambda self: self._desc.keyType, constant=True) ================================================ FILE: meshroom/core/mtyping.py ================================================ """ Common typing aliases used in Meshroom. """ from pathlib import Path from typing import Union PathLike = Union[Path, str] ================================================ FILE: meshroom/core/node.py ================================================ #!/usr/bin/env python import sys import atexit import copy import datetime import json import logging import os import platform import re import shutil import time import uuid from collections import namedtuple, OrderedDict from enum import Enum, auto from typing import Callable, Optional, List import meshroom from meshroom.common import Signal, Variant, Property, BaseObject, Slot, ListModel, DictModel from meshroom.core import desc, plugins, stats, hashValue, nodeVersion, Version, MrNodeType from meshroom.core.attribute import attributeFactory, ListAttribute, GroupAttribute, Attribute from meshroom.core.exception import NodeUpgradeError, UnknownNodeTypeError from meshroom.core.mtyping import PathLike def getWritingFilepath(filepath: str) -> str: return filepath + '.writing.' + str(uuid.uuid4()) def renameWritingToFinalPath(writingFilepath: str, filepath: str) -> str: if platform.system() == 'Windows': # On Windows, attempting to remove a file that is in use causes an exception to be raised. # So we may need multiple trials, if someone is reading it at the same time. for _ in range(20): try: os.remove(filepath) # If remove is successful, we can stop the iterations break except OSError: pass os.rename(writingFilepath, filepath) class Status(Enum): """ """ NONE = 0 SUBMITTED = 1 RUNNING = 2 ERROR = 3 STOPPED = 4 KILLED = 5 SUCCESS = 6 INPUT = 7 # Special status for input nodes class ExecMode(Enum): """ """ NONE = auto() LOCAL = auto() EXTERN = auto() # Simple structure for storing chunk information NodeChunkSetup = namedtuple("NodeChunks", ["blockSize", "fullSize", "nbBlocks"]) class NodeStatusData(BaseObject): __slots__ = ("nodeName", "nodeType", "status", "execMode", "packageName", "mrNodeType", "submitterSessionUid", "chunksBlockSize", "chunksFullSize", "chunksNbBlocks", "jobInfo") def __init__(self, nodeName='', nodeType='', packageName='', mrNodeType: MrNodeType = MrNodeType.NONE, parent: BaseObject = None): super().__init__(parent) self.nodeName: str = nodeName self.nodeType: str = nodeType self.packageName: str = packageName self.mrNodeType: str = mrNodeType # Session UID where the node was submitted self.submitterSessionUid: Optional[str] = None self.reset() def reset(self): self.resetChunkInfo() self.resetDynamicValues() def resetChunkInfo(self): self.chunks: NodeChunkSetup = None def resetDynamicValues(self): self.status: Status = Status.NONE self.execMode: ExecMode = ExecMode.NONE self.jobInfo: dict = {} def setNodeType(self, node): """ Set the node type and package information from the given node. We do not set the name in this method as it may vary if there are duplicates. """ self.nodeType = node.nodeType self.packageName = node.packageName self.mrNodeType = node.getMrNodeType() def setNode(self, node): """ Set the node information from one node instance. """ self.nodeName = node.name self.setNodeType(node) def setJob(self, jid, submitterName): """ Set Job information on the node. """ self.jobInfo = { "jid": str(jid), "submitterName": str(submitterName), } @property def jobName(self): if self.jobInfo: return f"{self.jobInfo['submitterName']}<{self.jobInfo['jid']}>" else: return "UNKNOWN" def initExternSubmit(self): """ When submitting a node, we reset the status information to ensure that we do not keep outdated information. """ self.resetDynamicValues() self.submitterSessionUid = meshroom.core.sessionUid self.status = Status.SUBMITTED self.execMode = ExecMode.EXTERN def initLocalSubmit(self): """ When submitting a node, we reset the status information to ensure that we do not keep outdated information. """ self.resetDynamicValues() self.submitterSessionUid = meshroom.core.sessionUid self.status = Status.SUBMITTED self.execMode = ExecMode.LOCAL def toDict(self): keys = list(self.__slots__) or [] d = {key:getattr(self, key, 0) for key in keys} for _k, _v in d.items(): if isinstance(_v, Enum): d[_k] = _v.name if self.chunks: d["chunksBlockSize"] = self.chunks.blockSize d["chunksFullSize"] = self.chunks.fullSize d["chunksNbBlocks"] = self.chunks.nbBlocks return d def fromDict(self, d): self.reset() if "mrNodeType" in d: self.mrNodeType = MrNodeType[d.pop("mrNodeType")] if "chunksBlockSize" in d and "chunksFullSize" in d and "chunksNbBlocks" in d: blockSize = int(d.pop("chunksBlockSize") or 0) fullSize = int(d.pop("chunksFullSize") or 0) nbBlocks = int(d.pop("chunksNbBlocks") or 0) self.chunks = NodeChunkSetup(blockSize, fullSize, nbBlocks) if "status" in d: self.status: Status = Status[d.pop("status")] if "execMode" in d: self.execMode = ExecMode[d.pop("execMode")] for _key, _value in d.items(): if _key in self.__slots__: setattr(self, _key, _value) def loadFromCache(self, statusFile): self.reset() try: with open(statusFile) as jsonFile: statusData = json.load(jsonFile) self.fromDict(statusData) except Exception as e: logging.warning(f"(loadFromCache) {self.nodeName}: Error while loading status file {statusFile}: {e}") self.reset() @property def nbChunks(self): nbBlocks = self.chunks.nbBlocks if self.chunks else -1 return nbBlocks @property def fullSize(self): fullSize = self.chunks.fullSize if self.chunks else -1 return fullSize def getChunkRanges(self): if not self.chunks: return [] ranges = [] for i in range(self.chunks.nbBlocks): ranges.append(desc.Range( iteration=i, blockSize=self.chunks.blockSize, fullSize=self.chunks.fullSize, nbBlocks=self.chunks.nbBlocks )) return ranges def setChunks(self, chunks): blockSize, fullSize, nbBlocks = 1, 1, 1 for c in chunks: r = c.range blockSize, fullSize, nbBlocks = r.blockSize, r.fullSize, r.nbBlocks break self.chunks = NodeChunkSetup(blockSize, fullSize, nbBlocks) class ChunkStatusData(BaseObject): """ """ dateTimeFormatting = '%Y-%m-%d %H:%M:%S.%f' __slots__ = ( "nodeName", "mrNodeType", "computeSessionUid", "execMode", "status", "commandLine", "startDateTime", "endDateTime", "elapsedTime", "hostname" ) def __init__(self, nodeName='', mrNodeType: MrNodeType = MrNodeType.NONE, parent: BaseObject = None): super().__init__(parent) self.nodeName: str = nodeName self.mrNodeType = mrNodeType self.computeSessionUid: Optional[str] = None # Session where computation is done self.execMode: ExecMode = ExecMode.NONE self.resetDynamicValues() def resetDynamicValues(self): self.status: Status = Status.NONE self.commandLine: str = "" self._startTime: Optional[datetime.datetime] = None self.startDateTime: str = "" self.endDateTime: str = "" self.elapsedTime: float = 0.0 self.hostname: str = "" def setNode(self, node): """ Set the node information from one node instance. """ self.nodeName = node.name self.mrNodeType = node.getMrNodeType() def merge(self, other): self.startDateTime = min(self.startDateTime, other.startDateTime) self.endDateTime = max(self.endDateTime, other.endDateTime) self.elapsedTime += other.elapsedTime def reset(self): self.nodeName: str = "" self.mrNodeType: MrNodeType = MrNodeType.NONE self.execMode: ExecMode = ExecMode.NONE self.resetDynamicValues() def initStartCompute(self): import platform self.computeSessionUid = meshroom.core.sessionUid self.hostname = platform.node() self._startTime = time.time() self.startDateTime = datetime.datetime.now().strftime(self.dateTimeFormatting) # to get datetime obj: datetime.datetime.strptime(obj, self.dateTimeFormatting) self.status = Status.RUNNING # Note: We do not modify the "execMode" here, as it is set in the init*Submit methods. # When we compute (from renderfarm or isolated environment), # we do not want to modify the execMode set from the submit. def initIsolatedCompute(self): """ When submitting a node, we reset the status information to ensure that we do not keep outdated information. """ self.resetDynamicValues() self.initStartCompute() assert self.mrNodeType == MrNodeType.NODE self.computeSessionUid = None def initExternSubmit(self): """ When submitting a node, we reset the status information to ensure that we do not keep outdated information. """ self.resetDynamicValues() self.computeSessionUid = None self.status = Status.SUBMITTED self.execMode = ExecMode.EXTERN def initLocalSubmit(self): """ When submitting a node, we reset the status information to ensure that we do not keep outdated information. """ self.resetDynamicValues() self.computeSessionUid = None self.status = Status.SUBMITTED self.execMode = ExecMode.LOCAL def initEndCompute(self): self.computeSessionUid = meshroom.core.sessionUid self.endDateTime = datetime.datetime.now().strftime(self.dateTimeFormatting) if self._startTime != None: self.elapsedTime = time.time() - self._startTime @property def elapsedTimeStr(self): return str(datetime.timedelta(seconds=self.elapsedTime)) def toDict(self): keys = list(self.__slots__) or [] d = {key:getattr(self, key) for key in keys} for _k, _v in d.items(): if isinstance(_v, Enum): d[_k] = _v.name return d def fromDict(self, d): self.reset() if "status" in d: self.status: Status = Status[d.pop("status")] if "execMode" in d: self.execMode = ExecMode[d.pop("execMode")] if "mrNodeType" in d: self.mrNodeType = MrNodeType[d.pop("mrNodeType")] for _key, _value in d.items(): if _key in self.__slots__: setattr(self, _key, _value) class LogManager: dateTimeFormatting = '%H:%M:%S' def __init__(self, logger, logFile): self.logger: logging.Logger = logger self.logFile: PathLike = logFile self._previousHandlers: List[logging.Handler] = [] self._previousLevel: int = 0 class Formatter(logging.Formatter): def format(self, record): # Make level name lower case record.levelname = record.levelname.lower() return logging.Formatter.format(self, record) def configureLogger(self): self._previousLevel = self.logger.level self._previousHandlers = [] for handler in self.logger.handlers[:]: self._previousHandlers.append(handler) self.logger.removeHandler(handler) handler = logging.FileHandler(self.logFile) formatter = self.Formatter('[%(asctime)s.%(msecs)03d][%(levelname)s] %(message)s', self.dateTimeFormatting) handler.setFormatter(formatter) self.logger.addHandler(handler) def restorePreviousLogger(self): for h in self.logger.handlers[:]: self.logger.removeHandler(h) for h in self._previousHandlers: self.logger.addHandler(h) self.logger.setLevel(self._previousLevel) def clearLogFile(self): open(self.logFile, 'w').close() def start(self, level): # Make sure the log file exists if not os.path.exists(self.logFile): self.clearLogFile() self.configureLogger() self.logger.propagate = False self.logger.setLevel(self.textToLevel(level)) self.progressBar = False def end(self): for handler in self.logger.handlers[:]: # Stops the file being locked handler.close() def makeProgressBar(self, end, message=''): assert end > 0 assert not self.progressBar self.progressEnd = end self.currentProgressTics = 0 self.progressBar = True with open(self.logFile, 'a') as f: if message: f.write(message+'\n') f.write('0% 10 20 30 40 50 60 70 80 90 100%\n') f.write('|----|----|----|----|----|----|----|----|----|----|\n\n') f.close() with open(self.logFile) as f: content = f.read() self.progressBarPosition = content.rfind('\n') f.close() def updateProgressBar(self, value): assert self.progressBar assert value <= self.progressEnd tics = round((value/self.progressEnd)*51) with open(self.logFile, 'r+') as f: text = f.read() for i in range(tics-self.currentProgressTics): text = text[:self.progressBarPosition]+'*'+text[self.progressBarPosition:] f.seek(0) f.write(text) f.close() self.currentProgressTics = tics def completeProgressBar(self): assert self.progressBar self.progressBar = False @staticmethod def textToLevel(text): text = text.lower() if text in ["critical", "fatal"]: return logging.CRITICAL elif text == "error": return logging.ERROR elif text == "warning": return logging.WARNING elif text == "info": return logging.INFO elif text == "debug": return logging.DEBUG elif text == "trace": return logging.TRACE else: return logging.NOTSET runningProcesses: dict[str, "NodeChunk"] = {} @atexit.register def clearProcessesStatus(): for k, v in runningProcesses.items(): v.upgradeStatusTo(Status.KILLED) class NodeChunk(BaseObject): def __init__(self, node, range, parent=None): super().__init__(parent) self.node = node self.range = range self._logManager = None self._status: ChunkStatusData = ChunkStatusData(nodeName=node.name, mrNodeType=node.getMrNodeType()) self.statistics: stats.Statistics = stats.Statistics() self.statusFileLastModTime = -1 self.subprocess = None # Notify update in filepaths when node's internal folder changes self.node.internalFolderChanged.connect(self.nodeFolderChanged) def __repr__(self): return f"" @property def index(self): return self.range.iteration @property def name(self): if self.range.blockSize: return f"{self.node.name}({self.index})" else: return self.node.name @property def logManager(self): if self._logManager is None: logger = logging.getLogger(self.node.getName()) self._logManager = LogManager(logger, self.getLogFile()) return self._logManager def getStatusName(self): return self._status.status.name @property def logger(self): return self.logManager.logger def getExecModeName(self): return self._status.execMode.name def shouldMonitorChanges(self): """ Check whether we should monitor changes in minimal mode. Only chunks that are run externally or local_isolated should be monitored, when run locally, status changes are already notified. Chunks with an ERROR status may be re-submitted externally and should thus still be monitored. """ return (self.isExtern() and self._status.status in (Status.SUBMITTED, Status.RUNNING, Status.ERROR)) or \ (self.node.getMrNodeType() == MrNodeType.NODE and self._status.status in (Status.SUBMITTED, Status.RUNNING)) def updateStatusFromCache(self): """ Update chunk status based on status file content/existence. """ # TODO : If this is a placeholder chunk # Then we should not do anything here statusFile = self.getStatusFile() oldStatus = self._status.status # No status file => reset status to Status.None if not os.path.exists(statusFile): self.statusFileLastModTime = -1 self._status.reset() self._status.setNode(self.node) else: try: with open(statusFile) as jsonFile: statusData = json.load(jsonFile) # logging.debug(f"updateStatusFromCache({self.node.name}): From status {self._status.status} to {statusData['status']}") self._status.fromDict(statusData) self.statusFileLastModTime = os.path.getmtime(statusFile) except Exception as exc: logging.debug(f"updateStatusFromCache({self.node.name}): Error while loading status file {statusFile}: {exc}") self.statusFileLastModTime = -1 self._status.reset() self._status.setNode(self.node) if oldStatus != self._status.status: self.statusChanged.emit() def _getFile(self, fileType: str): """ Return the path for the requested type of file. It is expected to be prefixed by the chunk number, but for compatibility purposes, it may not be. """ chunkIndex = self.index if self.range.blockSize else 0 # Retro-compatibility: ensure we do not lose files computed when single chunks were not prefixed # If both the prefixed and not prefixed files exist, the prefixed one should be returned if os.path.exists(os.path.join(self.node.internalFolder, fileType)): if not os.path.exists(os.path.join(self.node.internalFolder, str(chunkIndex) + "." + fileType)): return os.path.join(self.node.internalFolder, fileType) return os.path.join(self.node.internalFolder, str(chunkIndex) + "." + fileType) def getStatusFile(self): return self._getFile("status") def getStatisticsFile(self): return self._getFile("statistics") def getLogFile(self): return self._getFile("log") def saveStatusFile(self): """ Write node status on disk. """ data = self._status.toDict() statusFilepath = self.getStatusFile() folder = os.path.dirname(statusFilepath) os.makedirs(folder, exist_ok=True) statusFilepathWriting = getWritingFilepath(statusFilepath) with open(statusFilepathWriting, 'w') as jsonFile: json.dump(data, jsonFile, indent=4) renameWritingToFinalPath(statusFilepathWriting, statusFilepath) def upgradeStatusFile(self): """ Upgrade node status file based on the current status. """ self.saveStatusFile() # We want to make sure the nodeStatus is up to date too self.node.upgradeStatusFile() self.statusChanged.emit() def upgradeStatusTo(self, newStatus, execMode=None): if newStatus.value < self._status.status.value: logging.warning(f"Downgrade status on node '{self.name}' from {self._status.status} to {newStatus}") if execMode is not None: self._status.execMode = execMode self._status.status = newStatus self.upgradeStatusFile() def updateStatisticsFromCache(self): """ """ oldTimes = self.statistics.times statisticsFile = self.getStatisticsFile() if not os.path.exists(statisticsFile): return with open(statisticsFile) as jsonFile: statisticsData = json.load(jsonFile) self.statistics.fromDict(statisticsData) if oldTimes != self.statistics.times: self.statisticsChanged.emit() def saveStatistics(self): data = self.statistics.toDict() statisticsFilepath = self.getStatisticsFile() folder = os.path.dirname(statisticsFilepath) os.makedirs(folder, exist_ok=True) statisticsFilepathWriting = getWritingFilepath(statisticsFilepath) with open(statisticsFilepathWriting, 'w') as jsonFile: json.dump(data, jsonFile, indent=4) renameWritingToFinalPath(statisticsFilepathWriting, statisticsFilepath) def isAlreadySubmitted(self): return self._status.status in (Status.SUBMITTED, Status.RUNNING) def isAlreadySubmittedOrFinished(self): return self._status.status in (Status.SUBMITTED, Status.RUNNING, Status.SUCCESS) def isFinishedOrRunning(self): return self._status.status in (Status.SUCCESS, Status.RUNNING) def isRunning(self): return self._status.status == Status.RUNNING def isStopped(self): return self._status.status == Status.STOPPED def isFinished(self): return self._status.status == Status.SUCCESS def process(self, forceCompute=False, inCurrentEnv=False): if not forceCompute and self._status.status == Status.SUCCESS: logging.info(f"Node chunk already computed: {self.name}") return # Start the process environment for nodes running in isolation. # This only happens once, when the node has the SUBMITTED status. # The sub-process will go through this method again, but the node status will # have been set to RUNNING. if not inCurrentEnv and self.node.getMrNodeType() == MrNodeType.NODE: self._processInIsolatedEnvironment() return runningProcesses[self.name] = self self._status.setNode(self.node) self._status.initStartCompute() self.upgradeStatusFile() executionStatus = None self.statThread = stats.StatisticsThread(self) self.statThread.start() try: logging.info(f"[Process chunk] Start processing...") self.node.nodeDesc.processChunk(self) # NOTE: this assumes saving the output attributes for each chunk self.node.saveOutputAttr() executionStatus = Status.SUCCESS except Exception: self.updateStatusFromCache() # check if the status has been updated by another process if self._status.status != Status.STOPPED: executionStatus = Status.ERROR raise except (KeyboardInterrupt, SystemError, GeneratorExit): executionStatus = Status.STOPPED raise finally: self._status.setNode(self.node) self._status.initEndCompute() self.upgradeStatusFile() if executionStatus: self.upgradeStatusTo(executionStatus) logging.info(f"[Process chunk] elapsed time: {self._status.elapsedTimeStr}") # Ask and wait for the stats thread to stop self.statThread.stopRequest() self.statThread.join() self.statistics = stats.Statistics() del runningProcesses[self.name] def _processInIsolatedEnvironment(self): """ Process this node chunk in the isolated environment defined in the environment configuration. """ try: self._status.setNode(self.node) self._status.initIsolatedCompute() self.upgradeStatusFile() self.node.nodeDesc.processChunkInEnvironment(self) except Exception: # status should be already updated by meshroom_compute self.updateStatusFromCache() if self._status.status not in (Status.ERROR, Status.STOPPED, Status.KILLED): # If meshroom_compute has crashed or been killed, the status may have not been # set to ERROR. # In this particular case, we enforce it from here. self.upgradeStatusTo(Status.ERROR) raise # Update the chunk status. self.updateStatusFromCache() # Update the output attributes, as any chunk may have modified them. self.node.updateOutputAttr() def stopProcess(self): # Ensure that we are up-to-date self.updateStatusFromCache() if self._status.status != Status.RUNNING: # When we stop the process of a node with multiple chunks, the Node function will call # the stop function of each chunk. # So, the chunk status could be SUBMITTED, RUNNING or ERROR. if self._status.status is Status.SUBMITTED: self.upgradeStatusTo(Status.NONE) elif self._status.status in (Status.ERROR, Status.STOPPED, Status.KILLED, Status.SUCCESS, Status.NONE): # Nothing to do, the computation is already stopped. pass else: logging.debug(f"Cannot stop process: node is not running (status is: {self._status.status}).") return self.node.nodeDesc.stopProcess(self) # Update the status to get latest information before changing it self.updateStatusFromCache() self.upgradeStatusTo(Status.STOPPED) def isExtern(self): """ The computation is managed externally by another instance of Meshroom. In the ambiguous case of an isolated environment, it is considered as local as we can stop it (if it is run from the current Meshroom instance). """ if self._status.execMode == ExecMode.EXTERN: return True elif self._status.execMode == ExecMode.LOCAL: if self._status.status in (Status.SUBMITTED, Status.RUNNING): return meshroom.core.sessionUid not in (self.node._nodeStatus.submitterSessionUid, self._status.computeSessionUid) return False return False statusChanged = Signal() status = Property(Variant, lambda self: self._status, notify=statusChanged) statusName = Property(str, getStatusName, notify=statusChanged) execModeName = Property(str, getExecModeName, notify=statusChanged) statisticsChanged = Signal() nodeFolderChanged = Signal() statusFile = Property(str, getStatusFile, notify=nodeFolderChanged) logFile = Property(str, getLogFile, notify=nodeFolderChanged) statisticsFile = Property(str, getStatisticsFile, notify=nodeFolderChanged) nodeName = Property(str, lambda self: self.node.name, constant=True) statusNodeName = Property(str, lambda self: self._status.nodeName, notify=statusChanged) elapsedTime = Property(float, lambda self: self._status.elapsedTime, notify=statusChanged) # Simple structure for storing node position Position = namedtuple("Position", ["x", "y"]) # Initialize default coordinates values to 0 Position.__new__.__defaults__ = (0,) * len(Position._fields) class BaseNode(BaseObject): """ Base Abstract class for Graph nodes. """ # Regexp handling complex attribute names with recursive understanding of Lists and Groups # i.e: a.b, a[0], a[0].b.c[1] attributeRE = re.compile(r'\.?(?P\w+)(?:\[(?P\d+)\])?') def __init__(self, nodeType: str, position: Position = None, parent: BaseObject = None, uid: str = None, **kwargs): """ Create a new Node instance based on the given node description. Any other keyword argument will be used to initialize this node's attributes. Args: nodeType: name of the node type parent: this Node's parent **kwargs: attributes values """ super().__init__(parent) self._nodeType: str = nodeType self.nodeDesc: desc.BaseNode = None self.nodePlugin: plugins.Plugin = None # instantiate node description if nodeType is valid if meshroom.core.pluginManager.getRegisteredNodePlugin(nodeType): self.nodeDesc = meshroom.core.pluginManager.getRegisteredNodePlugin(nodeType).nodeDescriptor() self.nodePlugin = meshroom.core.pluginManager.getRegisteredNodePlugin(nodeType) self.packageName: str = "" self._internalFolder: str = "" self._sourceCodeFolder: str = self.nodeDesc.sourceCodeFolder if self.nodeDesc else "" self._internalFolderExp = "{cache}/{nodeType}/{uid}" # temporary unique name for this node self._name: str = f"_{nodeType}_{uuid.uuid1()}" self.graph = None self.dirty: bool = True # whether this node's outputs must be re-evaluated on next Graph update self._chunks: list[NodeChunk] = ListModel(parent=self) self._chunksCreated = False # Only initialize chunks on compute self._chunkPlaceholder: list[NodeChunk] = ListModel(parent=self) # Placeholder chunk for nodes with dynamic ones self._uid: str = uid self._expVars: dict = {} self._size: int = 0 self._logManager: Optional[LogManager] = None self._position: Position = position or Position() self._attributes = DictModel(keyAttrName='name', parent=self) self._internalAttributes = DictModel(keyAttrName='name', parent=self) self.invalidatingAttributes: set = set() self._alive: bool = True # for QML side to know if the node can be used or is going to be deleted self._locked: bool = False self._duplicates = ListModel(parent=self) # list of nodes with the same uid self._hasDuplicates: bool = False self._nodeStatus: NodeStatusData = NodeStatusData(self._name, nodeType, self.packageName, self.getMrNodeType()) self.nodeStatusFileLastModTime = -1 self.globalStatusChanged.connect(self.updateDuplicatesStatusAndLocked) self._staticExpVars = { "nodeType": self.nodeType, "nodeSourceCodeFolder": self.sourceCodeFolder } def __getattr__(self, k): try: # Throws exception if not in prototype chain return object.__getattribute__(self, k) except AttributeError as err: try: return self.attribute(k) except KeyError: raise err def getMrNodeType(self): # In compatibility mode, we may or may not have access to the nodeDesc and its information # about the node type. if self.nodeDesc is None: return MrNodeType.NONE return self.nodeDesc.getMrNodeType() def getName(self): return self._name def getDefaultLabel(self): return self.nameToLabel(self._name) def getLabel(self) -> str: """ Returns: The user-provided label if it exists, the high-level label of this node otherwise """ if self.hasInternalAttribute("label"): label = self.internalAttribute("label").value.strip() if label: return label return self.getDefaultLabel() def getNodeLogLevel(self) -> str: """ Returns: The user-provided log level used for logging on process launched by this node """ if self.hasInternalAttribute("nodeDefaultLogLevel"): return self.internalAttribute("nodeDefaultLogLevel").value.strip() return "info" def getColor(self) -> str: """ Returns: The node's color: the user-provided custom color if set, otherwise the descriptor's default color (nodeDesc.color), or empty string if neither is defined. """ if self.hasInternalAttribute("color"): return self.internalAttribute("color").value.strip() return "" def getInvalidationMessage(self) -> str: """ Returns: The invalidation message on the node if it exists, empty string otherwise """ if self.hasInternalAttribute("invalidation"): return self.internalAttribute("invalidation").value return "" def getComment(self) -> str: """ Returns: The comments on the node if they exist, empty string otherwise """ if self.hasInternalAttribute("comment"): return self.internalAttribute("comment").value return "" def getFontSize(self) -> int: """ Returns: The font size from the node if it exists, 0 otherwise. """ if self.hasInternalAttribute("fontSize"): return self.internalAttribute("fontSize").value return 0 def getFontColor(self) -> str: """ Returns: The color of the font from the node if it exists, empty string otherwise. """ if self.hasInternalAttribute("fontColor"): return self.internalAttribute("fontColor").value.strip() return "" def getNodeWidth(self) -> int: """ Returns: The width of the node if it has a user-set width, 0 otherwise. """ if self.hasInternalAttribute("nodeWidth"): return self.internalAttribute("nodeWidth").value return 0 def getNodeHeight(self) -> int: """ Returns: The height of the node if it has a user-set height, 0 otherwise. """ if self.hasInternalAttribute("nodeHeight"): return self.internalAttribute("nodeHeight").value return 0 @Slot(str, result=str) def nameToLabel(self, name): """ Returns: str: the high-level label from the technical node name """ t, idx = name.rsplit("_", 1) if "_" in name else (name, "1") return f"{t}{idx if int(idx) > 1 else ''}" def getDocumentation(self): if not self.nodeDesc: return "" if self.nodeDesc.documentation: return self.nodeDesc.documentation else: return self.nodeDesc.__doc__ def getNodeInfo(self): if not self.nodeDesc: return [] info = OrderedDict([ ("module", self.nodeDesc.__module__), ("modulePath", self.nodeDesc.plugin.path), ]) # > Info from the plugin module plugin_module = sys.modules.get(self.nodeDesc.__module__) if getattr(plugin_module, "__author__", None): info["author"] = plugin_module.__author__ if getattr(plugin_module, "__license__", None): info["license"] = plugin_module.__license__ if getattr(plugin_module, "__version__", None): info["version"] = plugin_module.__version__ # > Overrides at the node-level if getattr(self.nodeDesc, "author", None): info["author"] = self.nodeDesc.author if getattr(self.nodeDesc, "version", None): info["version"] = self.nodeDesc.version # > Additional node information stored in a __nodeInfo__ parameter additionalNodeInfo = getattr(self.nodeDesc, "__nodeInfo__", None) if additionalNodeInfo: for key, value in additionalNodeInfo: info[key] = value return [{"key": k, "value": v} for k, v in info.items()] @Slot(str, result=Attribute) def attribute(self, name): att = None # Complex name indicating group or list attribute if '[' in name or '.' in name: p = self.attributeRE.findall(name) for n, idx in p: # first step: get root attribute if att is None: att = self._attributes.get(n) else: # get child Attribute in Group assert isinstance(att, GroupAttribute) att = att.value.get(n) if idx != '': # get child Attribute in List assert isinstance(att, ListAttribute) att = att.value.at(int(idx)) else: att = self._attributes.getr(name) return att @Slot(str, result=Attribute) def internalAttribute(self, name): # No group or list attributes for internal attributes # The internal attribute itself can be returned directly return self._internalAttributes.get(name) def setInternalAttributeValues(self, values): # initialize internal attribute values for k, v in values.items(): attr = self.internalAttribute(k) attr.value = v def getAttributes(self): return self._attributes def getInternalAttributes(self): return self._internalAttributes @Slot(str, result=bool) def hasAttribute(self, name): # Complex name indicating group or list attribute: parse it and get the # first output element to check for the attribute's existence if "[" in name or "." in name: p = self.attributeRE.findall(name) return p[0][0] in self._attributes.keys() or p[0][1] in self._attributes.keys() return name in self._attributes.keys() @Slot(str, result=bool) def hasInternalAttribute(self, name): return name in self._internalAttributes.keys() def _applyExpr(self): for attr in self._attributes: attr._applyExpr() @property def nodeType(self): return self._nodeType @property def position(self): """ Get node position. """ return self._position @position.setter def position(self, value): """ Set node position. Args: value (Position): target position """ if self._position == value: return self._position = value self.positionChanged.emit() @property def alive(self): return self._alive @alive.setter def alive(self, value): if self._alive == value: return self._alive = value self.aliveChanged.emit() @property def depth(self): return self.graph.getDepth(self) @property def minDepth(self): return self.graph.getDepth(self, minimal=True) @property def valuesFile(self): return os.path.join(self.internalFolder, 'values') def getInputNodes(self, recursive, dependenciesOnly): return self.graph.getInputNodes(self, recursive=recursive, dependenciesOnly=dependenciesOnly) def getOutputNodes(self, recursive, dependenciesOnly): return self.graph.getOutputNodes(self, recursive=recursive, dependenciesOnly=dependenciesOnly) def toDict(self): pass def _computeUid(self): """ Compute node UID by combining associated attributes' UIDs. """ # If there is no invalidating attribute, then the computation of the UID should not # go through as it will only include the node type if not self.invalidatingAttributes: return # UID is computed by hashing the sorted list of tuple (name, value) of all attributes # impacting this UID uidAttributes = [] for attr in self.invalidatingAttributes: if not attr.enabled: continue # Disabled params do not contribute to the uid dynamicOutputAttr = attr.isLink and attr.inputRootLink.desc.isDynamicValue # For dynamic output attributes, the UID does not depend on the attribute value. # In particular, when loading a project file, the UIDs are updated first, # and the node status and the dynamic output values are not yet loaded, # so we should not read the attribute value. if not dynamicOutputAttr and not attr.keyable and attr.value == attr.desc.uidIgnoreValue: continue # For non-dynamic attributes, check if the value should be ignored uidAttributes.append((attr.name, attr.uid())) uidAttributes.sort() # Adding the node type prevents ending up with two identical UIDs for different node types # that have the exact same list of attributes uidAttributes.append(self.nodeType) self._uid = hashValue(uidAttributes) def _computeInternalFolder(self, cacheDir): self._internalFolder = self._internalFolderExp.format( cache=cacheDir or self.graph.cacheDir, nodeType=self.nodeType, uid=self._uid) def _buildExpVars(self): """ Generate command variables using input attributes and resolved output attributes names and values. """ def _buildAttributeExpVars(expVars, name, attr): if attr.enabled: # xxValue is exposed without quotes to allow to compose expressions expVars[name + "Value"] = attr.getValueStr(withQuotes=False) if isinstance(attr, GroupAttribute): assert isinstance(attr.value, DictModel) # If the GroupAttribute is not set in a single command line argument, # the sub-attributes may need to be exposed individually for v in attr._value: _buildAttributeExpVars(expVars, v.name, v) self._expVars = { "uid": self._uid, "nodeCacheFolder": self._internalFolder, "node": self, } # Evaluate input params for name, attr in self._attributes.objects.items(): if attr.isOutput: continue # skip outputs _buildAttributeExpVars(self._expVars, name, attr) # For updating output attributes invalidation values expVarsNoCache = self._expVars.copy() expVarsNoCache["cache"] = "" # Use "self._internalFolder" instead of "self.internalFolder" because we do not want it to # be resolved with the {cache} information ("self.internalFolder" resolves # "self._internalFolder") expVarsNoCache["nodeCacheFolder"] = self._internalFolderExp.format(**expVarsNoCache, **self._staticExpVars) # Evaluate output params for name, attr in self._attributes.objects.items(): if attr.isInput: continue # skip inputs # Apply expressions for File attributes if attr.desc.isExpression: defaultValue = "" # Do not evaluate expression for disabled attributes # (the expression may refer to other attributes that are not defined) if attr.enabled: try: defaultValue = attr.getDefaultValue() except AttributeError: # If we load an old scene, the lambda associated to the 'value' could try to # access other params that could not exist yet logging.warning(f'Invalid lambda evaluation for "{self.name}.{attr.name}"') if defaultValue is not None: try: attr.value = defaultValue.format(**self._expVars) attr._invalidationValue = defaultValue.format(**expVarsNoCache) except KeyError as err: logging.warning(f'Invalid expression with missing key on "{self.name}.{attr.name}" with ' f'value "{defaultValue}".\nError: {str(err)}') except ValueError as err: logging.warning(f'Invalid expression value on "{self.name}.{attr.name}" with value ' f'"{defaultValue}".\nError: {str(err)}') # xxValue is exposed without quotes to allow to compose expressions self._expVars[name + 'Value'] = attr.getValueStr(withQuotes=False) def createCmdLineVars(self): """ Generate command variables using input attributes and resolved output attributes names and values. """ def _buildAttributeCmdLineVars(cmdLineVars, name, attr): if attr.enabled: group = attr.desc.commandLineGroup(attr.node) \ if callable(attr.desc.commandLineGroup) else attr.desc.commandLineGroup if group: # If there is a valid command line "group" v = attr.getValueStr(withQuotes=True) # List elements may give a fully empty string and will not be sent to the command line. # String attributes will return only quotes if it is empty and thus will be send to the command line. # But a List of string containing 1 element, # and this element is an empty string will also return quotes and will be sent to the command line. if v: cmdLineVars[group] = cmdLineVars.get(group, "") + f" --{name} {v}" elif isinstance(attr, GroupAttribute): assert isinstance(attr.value, DictModel) # If the GroupAttribute is not set in a single command line argument, # the sub-attributes may need to be exposed individually for v in attr._value: _buildAttributeCmdLineVars(cmdLineVars, v.name, v) cmdLineVars = {} # Evaluate input params for name, attr in self._attributes.objects.items(): if attr.isOutput: continue # skip outputs _buildAttributeCmdLineVars(cmdLineVars, name, attr) # Evaluate output params for name, attr in self._attributes.objects.items(): if attr.isInput: continue # skip inputs if not attr.desc.commandLineGroup: continue # skip attributes without group v = attr.getValueStr(withQuotes=True) if not v: continue # skip empty strings cmdLineVars[attr.desc.commandLineGroup] = \ cmdLineVars.get(attr.desc.commandLineGroup, '') + f' --{name} {v}' return cmdLineVars @property def isParallelized(self): return bool(self.nodeDesc.parallelization) if meshroom.useMultiChunks else False @property def cpu(self): """ Return the resolved CPU level for this node, by evaluating the descriptor's `cpu` attribute with this node instance if it is callable. """ if self.nodeDesc is None: return None return self.nodeDesc.resolvedCpu(self) @property def gpu(self): """ Return the resolved GPU level for this node, by evaluating the descriptor's `gpu` attribute with this node instance if it is callable. """ if self.nodeDesc is None: return None return self.nodeDesc.resolvedGpu(self) @property def ram(self): """ Return the resolved RAM level for this node, by evaluating the descriptor's `ram` attribute with this node instance if it is callable. """ if self.nodeDesc is None: return None return self.nodeDesc.resolvedRam(self) def hasStatus(self, status: Status): if not self._chunks or not self._chunksCreated: if self.isInputNode: return status == Status.INPUT return status == Status.NONE for chunk in self._chunks: if chunk.status.status != status: return False return True def _isComputed(self): if not self.isComputableType: return True return self.hasStatus(Status.SUCCESS) def _isComputableType(self): """ Return True if this node type is computable, False otherwise. A computable node type can be in a context that does not allow computation. """ # Ambiguous case for NONE, which could be used for compatibility nodes if we do not have # any information about the node descriptor. return self.getMrNodeType() != MrNodeType.INPUT and self.getMrNodeType() != MrNodeType.BACKDROP def clearData(self): """ Delete this Node internal folder. Status will be reset to Status.NONE """ # Clear cache self._nodeStatus.reset() # Reset chunks self._resetChunks() if self.internalFolder and os.path.exists(self.internalFolder): try: shutil.rmtree(self.internalFolder) except Exception as exc: # We could get some "Device or resource busy" on .nfs file while removing the folder # on Linux network. # On Windows, some output files may be open for visualization and the removal will # fail. # In both cases, we can ignore it. logging.warning(f"Failed to remove internal folder: '{self.internalFolder}'. Error: {exc}.") self.updateStatusFromCache() @Slot(result=str) def getStartDateTime(self): """ Return the date (str) of the first running chunk """ dateTime = [chunk._status.startDateTime for chunk in self._chunks if chunk._status.status not in (Status.NONE, Status.SUBMITTED) and chunk._status.startDateTime != ""] return min(dateTime) if len(dateTime) != 0 else "" def isAlreadySubmitted(self): if self._chunksCreated: return any(c.isAlreadySubmitted() for c in self._chunks) else: return self._nodeStatus.status in (Status.SUBMITTED, Status.RUNNING) def isAlreadySubmittedOrFinished(self): if self._chunksCreated: return all(c.isAlreadySubmittedOrFinished() for c in self._chunks) else: return self._nodeStatus.status in (Status.SUBMITTED, Status.RUNNING, Status.SUCCESS) @Slot(result=bool) def isSubmittedOrRunning(self): """ Return True if all chunks are at least submitted and there is one running chunk, False otherwise. """ if not self._chunksCreated: return False if not self.isAlreadySubmittedOrFinished(): return False for chunk in self._chunks: if chunk.isRunning(): return True return False @Slot(result=bool) def isRunning(self): """ Return True if at least one chunk of this Node is running, False otherwise. """ return any(chunk.isRunning() for chunk in self._chunks) @Slot(result=bool) def isFinishedOrRunning(self): """ Return True if all chunks of this Node is either finished or running, False otherwise. """ if not self._chunks: return False return all(chunk.isFinishedOrRunning() for chunk in self._chunks) @Slot(result=bool) def isPartiallyFinished(self): """ Return True is at least one chunk of this Node is finished, False otherwise. """ return any(chunk.isFinished() for chunk in self._chunks) def isExtern(self): """ Return True if at least one chunk of this Node has an external execution mode, False otherwise. It is not enough to check whether the first chunk's execution mode is external, because computations may have been started locally, interrupted, and restarted externally. In that case, if the first chunk has completed locally before the computations were interrupted, its execution mode will always be local, even if computations resume externally. """ if not self._chunksCreated: if self._nodeStatus.execMode == ExecMode.EXTERN: return True elif self._nodeStatus.execMode == ExecMode.LOCAL and self._nodeStatus.status in (Status.SUBMITTED, Status.RUNNING): return meshroom.core.sessionUid != self._nodeStatus.submitterSessionUid return False return any(chunk.isExtern() for chunk in self._chunks) @Slot() def clearSubmittedChunks(self): """ Reset all submitted chunks to Status.NONE. This method should be used to clear inconsistent status if a computation failed without informing the graph. Warnings: This must be used with caution. This could lead to inconsistent node status if the graph is still being computed. """ if self._chunksCreated: for chunk in self._chunks: if chunk.isAlreadySubmitted(): chunk.upgradeStatusTo(Status.NONE, ExecMode.NONE) else: if self.isAlreadySubmitted(): self.upgradeStatusTo(Status.NONE, ExecMode.NONE) self.globalStatusChanged.emit() def clearLocallySubmittedChunks(self): """ Reset all locally submitted chunks to Status.NONE. """ if self._chunksCreated: for chunk in self._chunks: if chunk.isAlreadySubmitted() and not chunk.isExtern(): chunk.upgradeStatusTo(Status.NONE, ExecMode.NONE) else: if self.isAlreadySubmitted() and not self.isExtern(): self.upgradeStatusTo(Status.NONE, ExecMode.NONE) self.globalStatusChanged.emit() def upgradeStatusTo(self, newStatus, execMode=None): """ Upgrade node to the given status and save it on disk. """ if self._chunksCreated: for chunk in self._chunks: chunk.upgradeStatusTo(newStatus) else: if execMode is not None: self._nodeStatus.execMode = execMode self._nodeStatus.status = newStatus self.upgradeStatusFile() chunkPlaceholder = NodeChunk(self, desc.computation.Range()) chunkPlaceholder._status.execMode = self._nodeStatus.execMode chunkPlaceholder._status.status = self._nodeStatus.status self.chunkPlaceholder.setObjectList([chunkPlaceholder]) self.chunksChanged.emit() self.globalStatusChanged.emit() def updateStatisticsFromCache(self): for chunk in self._chunks: chunk.updateStatisticsFromCache() def _resetChunks(self): pass def createChunksFromCache(self): pass def _createChunks(self): pass def evaluateSize(self): """ Evaluate the node size by delegating to the descriptor's resolvedSize classmethod. """ return self.nodeDesc.resolvedSize(self) def _updateNodeSize(self): self.setSize(self.evaluateSize()) def _getAttributeChangedCallback(self, attr: Attribute) -> Optional[Callable]: """ Get the node descriptor-defined value changed callback associated to `attr` if any. """ # Callbacks cannot be defined on nested attributes. if attr.root is not None: return None attrCapitalizedName = attr.name[:1].upper() + attr.name[1:] callbackName = f"on{attrCapitalizedName}Changed" callback = getattr(self.nodeDesc, callbackName, None) return callback if callback and callable(callback) else None def _onAttributeChanged(self, attr: Attribute): """ When an attribute value has changed, a specific function can be defined in the descriptor and be called. Args: attr: The Attribute that has changed. """ if self.isCompatibilityNode: # Compatibility nodes are not meant to be updated. return if attr.isOutput and not self.isInputNode: # Ignore changes on output attributes for non-input nodes # as they are updated during the node's computation. # And we do not want notifications during the graph processing. return if not attr.keyable and attr.value is None: # Discard dynamic values depending on the graph processing. return if self.graph and self.graph.isLoading: # Do not trigger attribute callbacks during the graph loading. return callback = self._getAttributeChangedCallback(attr) if callback: callback(self) if self.graph: # If we are in a graph, propagate the notification to the connected output attributes for edge in self.graph.outEdges(attr): edge.dst.valueChanged.emit() def onAttributeClicked(self, attr): """ When an attribute is clicked, a specific function can be defined in the descriptor and be called. Args: attr (Attribute): attribute that has been clicked """ paramName = attr.name[:1].upper() + attr.name[1:] methodName = f'on{paramName}Clicked' if hasattr(self.nodeDesc, methodName): m = getattr(self.nodeDesc, methodName) if callable(m): m(self) def updateInternals(self, cacheDir=None): """ Update Node's internal parameters and output attributes. This method is called when: - an input parameter is modified - the graph main cache directory is changed Args: cacheDir (str): (optional) override graph's cache directory with custom path """ if self.nodeDesc: self.nodeDesc.update(self) for attr in self._attributes: attr.updateInternals() # Reset chunks splitting self._resetChunks() # Retrieve current internal folder (if possible) try: folder = self.internalFolder except KeyError: folder = '' # Update command variables / output attributes self._computeUid() self._computeInternalFolder(cacheDir) self._buildExpVars() if self.nodeDesc: self.nodeDesc.postUpdate(self) # Notify internal folder change if needed if self._internalFolder != folder: self.internalFolderChanged.emit() def updateInternalAttributes(self): self.internalAttributesChanged.emit() @property def internalFolder(self): return self._internalFolder @property def sourceCodeFolder(self): return self._sourceCodeFolder @property def nodeStatusFile(self): return os.path.join(self.graph.cacheDir, self.internalFolder, "nodeStatus") def shouldMonitorChanges(self): """ Check whether we should monitor changes in minimal mode. Only chunks that are run externally or local_isolated should be monitored, when run locally, status changes are already notified. Chunks with an ERROR status may be re-submitted externally and should thus still be monitored """ if self._chunksCreated: # Only monitor when chunks are not created (in this case monitor chunk status files instead) return False return (self.isExtern() and self._nodeStatus.status in (Status.SUBMITTED, Status.RUNNING, Status.ERROR)) or \ (self.getMrNodeType() == MrNodeType.NODE and self._nodeStatus.status in (Status.SUBMITTED, Status.RUNNING)) def updateNodeStatusFromCache(self): """ Update node status based on status file content/existence. # TODO : integrate nodeStatusFileLastModTime ? Returns True if a change on the chunk setup has been detected """ chunksRangeHasChanged = False if os.path.exists(self.nodeStatusFile): oldChunkSetup = self._nodeStatus.chunks self._nodeStatus.loadFromCache(self.nodeStatusFile) if self._nodeStatus.chunks != oldChunkSetup: chunksRangeHasChanged = True self.nodeStatusFileLastModTime = os.path.getmtime(self.nodeStatusFile) else: # No status file => reset status to Status.None self.nodeStatusFileLastModTime = -1 self._nodeStatus.reset() self._nodeStatus.setNodeType(self) return chunksRangeHasChanged def updateStatusFromCache(self): """ Update node status based on status file content/existence. """ # Update nodeStatus from cache chunkChanged = self.updateNodeStatusFromCache() # Create chunks if we found info on them on the node cache if chunkChanged and self._nodeStatus.nbChunks > 0: # Update number of chunks try: self.createChunksFromCache() except Exception as e: logging.warning(f"Could not create chunks from cache: {e}") return s = self.globalStatus if self._chunksCreated: for chunk in self._chunks: chunk.updateStatusFromCache() else: # Restore placeholder chunk if needed chunkPlaceholder = NodeChunk(self, desc.computation.Range()) chunkPlaceholder._status.execMode = self._nodeStatus.execMode chunkPlaceholder._status.status = self._nodeStatus.status self._chunkPlaceholder.setObjectList([chunkPlaceholder]) # logging.debug(f"updateStatusFromCache: {self.name}, status: {s} => {self.globalStatus}") self.updateOutputAttr() def upgradeStatusFile(self): """ Write node status on disk. """ # Make sure the node has the globalStatus before saving it self._nodeStatus.status = self.getGlobalStatus() data = self._nodeStatus.toDict() statusFilepath = self.nodeStatusFile folder = os.path.dirname(statusFilepath) os.makedirs(folder, exist_ok=True) statusFilepathWriting = getWritingFilepath(statusFilepath) with open(statusFilepathWriting, 'w') as jsonFile: json.dump(data, jsonFile, indent=4) renameWritingToFinalPath(statusFilepathWriting, statusFilepath) def setJobId(self, jid, submitterName): self._nodeStatus.setJob(jid, submitterName) self.upgradeStatusFile() def initStatusOnSubmit(self, forceCompute=False): """ Prepare chunks status when the node is in a graph that was submitted """ hasChunkToLaunch = False if not self._chunksCreated: hasChunkToLaunch = True for chunk in self._chunks: if forceCompute or chunk._status.status != Status.SUCCESS: hasChunkToLaunch = True chunk._status.setNode(self) chunk._status.initExternSubmit() chunk.upgradeStatusFile() if hasChunkToLaunch: self._nodeStatus.setNode(self) self._nodeStatus.initExternSubmit() self.upgradeStatusFile() self.globalStatusChanged.emit() if self._nodeStatus.execMode == ExecMode.EXTERN and self._nodeStatus.status in (Status.RUNNING, Status.SUBMITTED): chunkPlaceholder = NodeChunk(self, desc.computation.Range()) chunkPlaceholder._status.execMode = self._nodeStatus.execMode chunkPlaceholder._status.status = self._nodeStatus.status self._chunkPlaceholder.setObjectList([chunkPlaceholder]) self.chunksChanged.emit() def initStatusOnCompute(self, forceCompute=False): hasChunkToLaunch = False if not self._chunksCreated: hasChunkToLaunch = True for chunk in self._chunks: if forceCompute or (chunk._status.status not in (Status.RUNNING, Status.SUCCESS)): hasChunkToLaunch = True chunk._status.setNode(self) chunk._status.initLocalSubmit() chunk.upgradeStatusFile() if hasChunkToLaunch: self._nodeStatus.setNode(self) self._nodeStatus.initLocalSubmit() self.upgradeStatusFile() self.globalStatusChanged.emit() if self._nodeStatus.execMode == ExecMode.LOCAL and self._nodeStatus.status in (Status.RUNNING, Status.SUBMITTED): chunkPlaceholder = NodeChunk(self, desc.computation.Range()) chunkPlaceholder._status.execMode = self._nodeStatus.execMode chunkPlaceholder._status.status = self._nodeStatus.status self._chunkPlaceholder.setObjectList([chunkPlaceholder]) self.chunksChanged.emit() def processIteration(self, iteration): self._chunks[iteration].process() def preprocess(self): # Invoke the Node Description's pre-process for the Client Node to prepare its processing self.nodeDesc.preprocess(self) def process(self, forceCompute=False, inCurrentEnv=False): for chunk in self._chunks: chunk.process(forceCompute, inCurrentEnv) def postprocess(self): # Invoke the post process on Client Node to execute after the processing on the # node is completed self.nodeDesc.postprocess(self) def getLogHandlers(self): return self._handlers def prepareLogger(self, iteration=-1): # Get file handler path chunkIndex = self.chunks[iteration].index if iteration != -1 else 0 logFileName = f"{chunkIndex}.log" logFile = os.path.join(self.internalFolder, logFileName) # Setup logger rootLogger = logging.getLogger() self._logManager = LogManager(rootLogger, logFile) self._logManager.clearLogFile() self._logManager.start(self.getNodeLogLevel()) def restoreLogger(self): self._logManager.restorePreviousLogger() def updateOutputAttr(self): if not self.nodeDesc: return if not self.nodeDesc.hasDynamicOutputAttribute: return # logging.warning(f"updateOutputAttr: {self.name}, status: {self.globalStatus}") if Status.SUCCESS in [c._status.status for c in self.getChunks()]: self.loadOutputAttr() else: self.resetOutputAttr() def resetOutputAttr(self): if not self.nodeDesc.hasDynamicOutputAttribute: return # logging.warning("resetOutputAttr: {}".format(self.name)) for output in self.nodeDesc.outputs: if output.isDynamicValue: if self.hasAttribute(output.name): self.attribute(output.name).value = None else: logging.warning(f"resetOutputAttr: Missing dynamic output attribute: {self.name}.{output.name}") def loadOutputAttr(self): """ Load output attributes with dynamic values from a values.json file. """ if not self.nodeDesc.hasDynamicOutputAttribute: return valuesFile = self.valuesFile if not os.path.exists(valuesFile): logging.warning(f"No output attr file: {valuesFile}") return # logging.warning("load output attr: {}, value: {}".format(self.name, valuesFile)) with open(valuesFile) as jsonFile: data = json.load(jsonFile) # logging.warning(data) for output in self.nodeDesc.outputs: if output.isDynamicValue: if self.hasAttribute(output.name) and output.name in data: self.attribute(output.name).value = data[output.name] else: if not self.hasAttribute(output.name): logging.warning(f"loadOutputAttr: Missing dynamic output attribute. Node={self.name}, " f"Attribute={output.name}") if output.name not in data: logging.warning(f"loadOutputAttr: Missing dynamic output value in file. Node={self.name}, " f"Attribute={output.name}, File={valuesFile}, Data keys={data.keys()}") def saveOutputAttr(self): """ Save output attributes with dynamic values into a values.json file. """ if not self.nodeDesc.hasDynamicOutputAttribute: return data = {} for output in self.nodeDesc.outputs: if output.isDynamicValue: if self.hasAttribute(output.name): data[output.name] = self.attribute(output.name).value else: logging.warning(f"saveOutputAttr: Missing dynamic output attribute: {self.name}.{output.name}") valuesFile = self.valuesFile # logging.warning("save output attr: {}, value: {}".format(self.name, valuesFile)) valuesFilepathWriting = getWritingFilepath(valuesFile) with open(valuesFilepathWriting, 'w') as jsonFile: json.dump(data, jsonFile, indent=4) renameWritingToFinalPath(valuesFilepathWriting, valuesFile) def endSequence(self): pass def stopComputation(self): """ Stop the computation of this node. """ if self._chunks: for chunk in self._chunks.values(): chunk.stopProcess() else: # Ensure that we are up-to-date self.updateNodeStatusFromCache() # The only status possible here is submitted if self._nodeStatus.status is Status.SUBMITTED: self.upgradeStatusTo(Status.NONE) def getGlobalStatus(self): """ Get node global status based on the status of its chunks. Returns: Status: the node global status """ if self.isInputNode: return Status.INPUT if not self._chunksCreated: # Get status from nodeStatus return self._nodeStatus.status if not self._chunks: return Status.NONE if len(self._chunks) == 1: return self._chunks[0]._status.status chunksStatus = [chunk._status.status for chunk in self._chunks] anyOf = (Status.ERROR, Status.STOPPED, Status.KILLED, Status.RUNNING, Status.SUBMITTED) allOf = (Status.SUCCESS,) for status in anyOf: if any(s == status for s in chunksStatus): return status for status in allOf: if all(s == status for s in chunksStatus): return status return Status.NONE @Slot(result=ChunkStatusData) def getFusedStatus(self): if not self._chunks: return ChunkStatusData() fusedStatus = ChunkStatusData() fusedStatus.fromDict(self._chunks[0]._status.toDict()) for chunk in self._chunks[1:]: fusedStatus.merge(chunk._status) fusedStatus.status = self.getGlobalStatus() return fusedStatus @Slot(result=ChunkStatusData) def getRecursiveFusedStatus(self): fusedStatus = self.getFusedStatus() nodes = self.getInputNodes(recursive=True, dependenciesOnly=True) for node in nodes: fusedStatus.merge(node.fusedStatus) return fusedStatus def _isCompatibilityNode(self): return False def _isInputNode(self): return isinstance(self.nodeDesc, desc.InputNode) def _isBackdropNode(self) -> bool: return False @property def globalExecMode(self): if not self._chunksCreated: return self._nodeStatus.execMode.name if len(self._chunks): return self._chunks.at(0).getExecModeName() else: return ExecMode.NONE def _getJobName(self): execMode = self._nodeStatus.execMode if execMode == ExecMode.LOCAL: return "LOCAL" elif execMode == ExecMode.EXTERN: return self._nodeStatus.jobName else: return "NONE" def getChunks(self) -> list[NodeChunk]: return self._chunks def getSize(self): return self._size def setSize(self, value): if self._size == value: return self._size = value self.sizeChanged.emit() def __repr__(self): return self.name def getLocked(self): return self._locked def setLocked(self, lock): if self._locked == lock: return self._locked = lock self.lockedChanged.emit() @Slot() def updateDuplicatesStatusAndLocked(self): """ Update status of duplicate nodes without any latency and update locked. """ if self.isMainNode(): for node in self._duplicates: node.updateStatusFromCache() self.updateLocked() def updateLocked(self): currentStatus = self.getGlobalStatus() lockedStatus = (Status.RUNNING, Status.SUBMITTED) # Unlock required nodes if the current node changes to Error, Stopped or None # Warning: we must handle some specific cases for global start/stop if self._locked and currentStatus in (Status.ERROR, Status.STOPPED, Status.NONE): self.setLocked(False) inputNodes = self.getInputNodes(recursive=True, dependenciesOnly=True) for node in inputNodes: if node.getGlobalStatus() == Status.RUNNING: # Return without unlocking if at least one input node is running # Example: using Cancel Computation on a submitted node return for node in inputNodes: node.setLocked(False) return # Avoid useless travel through nodes # For instance: when loading a scene with successful nodes if not self._locked and currentStatus == Status.SUCCESS: return if currentStatus == Status.SUCCESS: # At this moment, the node is necessarily locked because of previous if statement inputNodes = self.getInputNodes(recursive=True, dependenciesOnly=True) outputNodes = self.getOutputNodes(recursive=True, dependenciesOnly=True) stayLocked = None # Check if at least one dependentNode is submitted or currently running for node in outputNodes: if node.getGlobalStatus() in lockedStatus and node.isMainNode(): stayLocked = True break if not stayLocked: self.setLocked(False) # Unlock every input node for node in inputNodes: node.setLocked(False) return elif currentStatus in lockedStatus and self.isMainNode(): self.setLocked(True) inputNodes = self.getInputNodes(recursive=True, dependenciesOnly=True) for node in inputNodes: node.setLocked(True) return self.setLocked(False) def updateDuplicates(self, nodesPerUid): """ Update the list of duplicate nodes (sharing the same UID). """ if not nodesPerUid or not self._uid: if len(self._duplicates) > 0: self._duplicates.clear() self._hasDuplicates = False self.hasDuplicatesChanged.emit() return newList = [node for node in nodesPerUid.get(self._uid) if node != self] # If number of elements in both lists are identical, # we must check if their content is the same if len(newList) == len(self._duplicates): newListName = {node.name for node in newList} oldListName = {node.name for node in self._duplicates.values()} # If strict equality between both sets, # there is no need to set the new list if newListName == oldListName: return # Set the newList self._duplicates.setObjectList(newList) # Emit a specific signal 'hasDuplicates' to avoid extra binding # re-evaluation when the number of duplicates has changed if bool(len(newList)) != self._hasDuplicates: self._hasDuplicates = bool(len(newList)) self.hasDuplicatesChanged.emit() def initFromThisSession(self) -> bool: """ Check if the node was submitted from the current session """ if not self._chunksCreated or not self._chunks: return meshroom.core.sessionUid == self._nodeStatus.submitterSessionUid for chunk in self._chunks: # Technically the check on chunk._status.computeSessionUid is useless if meshroom.core.sessionUid not in (chunk._status.computeSessionUid, self._nodeStatus.submitterSessionUid): return False return True def isMainNode(self) -> bool: """ In case of a node with duplicates, we check that the node is the one driving the computation. """ if len(self._chunks) == 0: return True firstChunk = self._chunks.at(0) if not firstChunk.statusNodeName: # If nothing is declared, anyone could become the main (if there are duplicates). return True return firstChunk.statusNodeName == self.name @Slot(result=bool) def canBeStopped(self) -> bool: """ Return True if this node can be stopped, False otherwise. A node can be stopped if: - it has the "RUNNING" status (it is currently being computed) - it is executed locally and started from this Meshroom session OR it is executed externally on a render farm (and is thus associated to a job name). A node that is executed externally but without an associated job is likely a node that was started from another Meshroom instance, and thus cannot be stopped from this one. """ if not self.isComputableType: return False if self.isCompatibilityNode: return False # Only locked nodes running in local with the same # computeSessionUid as the Meshroom instance can be stopped return (self.getGlobalStatus() == Status.RUNNING and self.isMainNode() and ( (self.globalExecMode == ExecMode.LOCAL.name and self.initFromThisSession()) or (self.globalExecMode == ExecMode.EXTERN.name and self._nodeStatus.jobName != "UNKNOWN") ) ) @Slot(result=bool) def canBeCanceled(self) -> bool: """ Return True if this node can be canceled, False otherwise. A node can be canceled if: - it has the "SUBMITTED" status (it is not running yet, but is expected to be in the near future) - it is executed locally and started from this Meshroom session OR it is executed externally on a render farm (and is thus associated to a job name). A node that is executed externally but without an associated job is likely a node that was started from another Meshroom instance, and thus cannot be canceled from this one. """ if not self.isComputableType: return False if self.isCompatibilityNode: return False # Only locked nodes submitted in local with the same # computeSessionUid as the Meshroom instance can be canceled return (self.getGlobalStatus() == Status.SUBMITTED and self.isMainNode() and ( (self.globalExecMode == ExecMode.LOCAL.name and self.initFromThisSession()) or (self.globalExecMode == ExecMode.EXTERN.name and self._nodeStatus.jobName != "UNKNOWN") ) ) def hasImageOutputAttribute(self) -> bool: """ Return True if at least one attribute has the 'image' semantic (and can thus be loaded in the 2D Viewer), False otherwise. """ for attr in self._attributes: if not attr.enabled or not attr.isOutput: continue if attr.desc.semantic == "image": return True return False def hasSequenceOutputAttribute(self) -> bool: """ Return True if at least one attribute has the 'sequence' semantic (and can thus be loaded in the 2D Viewer), False otherwise. """ for attr in self._attributes: if not attr.enabled or not attr.isOutput: continue if attr.desc.semantic in ("sequence", "imageList"): return True return False def has3DOutputAttribute(self): """ Return True if at least one attribute is a File that can be loaded in the 3D Viewer, False otherwise. """ return next((attr for attr in self._attributes if attr.enabled and attr.isOutput and attr.is3dDisplayable), None) is not None def hasTextOutputAttribute(self) -> bool: """ Return True if at least one attribute is a text file that can be loaded in the Text Viewer, False otherwise. """ return next((attr for attr in self._attributes if attr.enabled and attr.isOutput and attr.isTextDisplayable), None) is not None def _hasDisplayableShape(self): """ Return True if at least one attribute is a ShapeAttribute, a ShapeListAttribute or a shape File. Note: These attributes can be loaded in the ShapeViewer / ShapeEditor. False otherwise. """ return next((attr for attr in self._attributes if attr.hasDisplayableShape or attr.desc.semantic == "shapeFile"), None) is not None nodeNameChanged = Signal() name = Property(str, getName, notify=nodeNameChanged) defaultLabel = Property(str, getDefaultLabel, constant=True) nodeType = Property(str, nodeType.fget, constant=True) documentation = Property(str, getDocumentation, constant=True) nodeInfo = Property(Variant, getNodeInfo, constant=True) nodeStatusChanged = Signal() nodeStatus = Property(Variant, lambda self: self._nodeStatus, notify=nodeStatusChanged) nodeStatusNodeName = Property(str, lambda self: self._nodeStatus.nodeName, notify=nodeStatusChanged) positionChanged = Signal() position = Property(Variant, position.fget, position.fset, notify=positionChanged) x = Property(float, lambda self: self._position.x, notify=positionChanged) y = Property(float, lambda self: self._position.y, notify=positionChanged) attributes = Property(BaseObject, getAttributes, constant=True) internalAttributes = Property(BaseObject, getInternalAttributes, constant=True) internalAttributesChanged = Signal() label = Property(str, getLabel, notify=internalAttributesChanged) color = Property(str, getColor, notify=internalAttributesChanged) invalidation = Property(str, getInvalidationMessage, notify=internalAttributesChanged) comment = Property(str, getComment, notify=internalAttributesChanged) fontSize = Property(int, getFontSize, notify=internalAttributesChanged) fontColor = Property(str, getFontColor, notify=internalAttributesChanged) nodeWidth = Property(int, getNodeWidth, notify=internalAttributesChanged) nodeHeight = Property(int, getNodeHeight, notify=internalAttributesChanged) internalFolderChanged = Signal() internalFolder = Property(str, internalFolder.fget, notify=internalFolderChanged) valuesFile = Property(str, valuesFile.fget, notify=internalFolderChanged) depthChanged = Signal() depth = Property(int, depth.fget, notify=depthChanged) minDepth = Property(int, minDepth.fget, notify=depthChanged) chunksCreatedChanged = Signal() chunksCreated = Property(bool, lambda self: self._chunksCreated, notify=chunksCreatedChanged) chunksChanged = Signal() chunks = Property(Variant, getChunks, notify=chunksChanged) chunkPlaceholder = Property(Variant, lambda self: self._chunkPlaceholder, notify=chunksChanged) nbParallelizationBlocks = Property(int, lambda self: len(self._chunks) if self._chunksCreated else 0, notify=chunksChanged) sizeChanged = Signal() size = Property(int, getSize, notify=sizeChanged) globalStatusChanged = Signal() globalStatus = Property(str, lambda self: self.getGlobalStatus().name, notify=globalStatusChanged) fusedStatus = Property(ChunkStatusData, getFusedStatus, notify=globalStatusChanged) elapsedTime = Property(float, lambda self: self.getFusedStatus().elapsedTime, notify=globalStatusChanged) recursiveElapsedTime = Property(float, lambda self: self.getRecursiveFusedStatus().elapsedTime, notify=globalStatusChanged) isCompatibilityNode = Property(bool, lambda self: self._isCompatibilityNode(), constant=True) isInputNode = Property(bool, lambda self: self._isInputNode(), constant=True) isBackdropNode = Property(bool, lambda self: self._isBackdropNode(), constant=True) globalExecMode = Property(str, globalExecMode.fget, notify=globalStatusChanged) jobName = Property(str, lambda self: self._getJobName(), notify=globalStatusChanged) isExternal = Property(bool, isExtern, notify=globalStatusChanged) isComputed = Property(bool, _isComputed, notify=globalStatusChanged) isComputableType = Property(bool, _isComputableType, notify=globalStatusChanged) aliveChanged = Signal() alive = Property(bool, alive.fget, alive.fset, notify=aliveChanged) lockedChanged = Signal() locked = Property(bool, getLocked, setLocked, notify=lockedChanged) duplicates = Property(Variant, lambda self: self._duplicates, constant=True) hasDuplicatesChanged = Signal() hasDuplicates = Property(bool, lambda self: self._hasDuplicates, notify=hasDuplicatesChanged) outputAttrChanged = Signal() hasImageOutput = Property(bool, hasImageOutputAttribute, notify=outputAttrChanged) hasSequenceOutput = Property(bool, hasSequenceOutputAttribute, notify=outputAttrChanged) has3DOutput = Property(bool, has3DOutputAttribute, notify=outputAttrChanged) hasTextOutput = Property(bool, hasTextOutputAttribute, notify=outputAttrChanged) # Whether the node contains a ShapeAttribute, a ShapeListAttribute or a shape File. hasDisplayableShape = Property(bool, _hasDisplayableShape, constant=True) class Node(BaseNode): """ A standard Graph node based on a node type. """ def __init__(self, nodeType, position=None, parent=None, uid=None, **kwargs): super().__init__(nodeType, position, parent=parent, uid=uid, **kwargs) if not self.nodeDesc: raise UnknownNodeTypeError(nodeType) self.packageName = self.nodeDesc.packageName for attrDesc in self.nodeDesc.inputs: self._attributes.add(attributeFactory(attrDesc, kwargs.get(attrDesc.name, None), isOutput=False, node=self)) for attrDesc in self.nodeDesc.outputs: self._attributes.add(attributeFactory(attrDesc, kwargs.get(attrDesc.name, None), isOutput=True, node=self)) for attrDesc in self.nodeDesc.internalInputs: self._internalAttributes.add(attributeFactory(attrDesc, kwargs.get(attrDesc.name, None), isOutput=False, node=self)) # Declare events for specific output attributes for attr in self._attributes: if attr.isOutput and attr.desc.semantic == "image": attr.enabledChanged.connect(self.outputAttrChanged) if attr.isOutput: attr.expressionApplied.connect(self.outputAttrChanged) # List attributes per UID for attr in self._attributes: if attr.isInput and attr.invalidate: self.invalidatingAttributes.add(attr) # Add internal attributes with a UID to the list for attr in self._internalAttributes: if attr.invalidate: self.invalidatingAttributes.add(attr) def setAttributeValues(self, values): # initialize attribute values for k, v in values.items(): if not self.hasAttribute(k): # skip missing attributes continue attr = self.attribute(k) attr.value = v def upgradeAttributeValues(self, values): # initialize attribute values for k, v in values.items(): if not self.hasAttribute(k): # skip missing attributes continue attr = self.attribute(k) try: attr.upgradeValue(v) except ValueError: pass def setInternalAttributeValues(self, values): # initialize internal attribute values for k, v in values.items(): if not self.hasInternalAttribute(k): # skip missing attributes continue attr = self.internalAttribute(k) attr.value = v def upgradeInternalAttributeValues(self, values): # initialize internal attibute values for k, v in values.items(): if not self.hasInternalAttribute(k): # skip missing atributes continue attr = self.internalAttribute(k) try: attr.upgradeValue(v) except ValueError: pass def toDict(self): inputs = {k: v.getSerializedValue() for k, v in self._attributes.objects.items() if v.isInput} internalInputs = {k: v.getSerializedValue() for k, v in self._internalAttributes.objects.items()} outputs = ({k: v.getSerializedValue() for k, v in self._attributes.objects.items() if v.isOutput and not v.desc.isDynamicValue}) return { 'nodeType': self.nodeType, 'position': self._position, 'parallelization': { 'blockSize': self.nodeDesc.parallelization.blockSize if self.isParallelized else 0, 'size': self.size, 'split': self.nbParallelizationBlocks }, 'uid': self._uid, 'inputs': {k: v for k, v in inputs.items() if v is not None}, # filter empty values 'internalInputs': {k: v for k, v in internalInputs.items() if v is not None}, 'outputs': outputs, } def _resetChunks(self): """ Set chunks on the node. # TODO : Maybe do not delete chunks if we will recreate them as before ? """ if self.isInputNode: self._chunksCreated = True return # Disconnect signals for chunk in self._chunks: chunk.statusChanged.disconnect(self.globalStatusChanged) # Empty list self._chunks.setObjectList([]) self._chunkPlaceholder.setObjectList([]) # Reset node status to ensure getGlobalStatus() returns NONE during the reset. # This prevents updateLocked() from using a stale status (e.g. SUCCESS or SUBMITTED) # which could cause the node to be incorrectly locked. self._nodeStatus.status = Status.NONE # Recreate list with reset values (1 chunk or the static size) if not self.isParallelized: self._chunks.setObjectList([NodeChunk(self, desc.Range())]) self._chunks[0].statusChanged.connect(self.globalStatusChanged) self._chunksCreated = True elif isinstance(self.nodeDesc.size, desc.computation.StaticNodeSize): self._updateNodeSize() self._chunks.setObjectList([NodeChunk(self, desc.Range())]) self._chunks[0].statusChanged.connect(self.globalStatusChanged) self._chunksCreated = True try: ranges = self.nodeDesc.parallelization.getRanges(self) self._chunks.setObjectList([NodeChunk(self, range) for range in ranges]) for c in self._chunks: c.statusChanged.connect(self.globalStatusChanged) logging.debug(f"Created {len(self._chunks)} chunks for node: {self.name}") except RuntimeError: # TODO: set node internal status to error logging.warning(f"Invalid Parallelization on node {self._name}") self._chunks.clear() self._chunksCreated = False else: self._chunksCreated = False self.setSize(0) self._chunkPlaceholder.setObjectList([NodeChunk(self, desc.computation.Range())]) # Create chunks when possible self.chunksCreatedChanged.emit() self.chunksChanged.emit() self.globalStatusChanged.emit() def __createChunks(self, ranges): if self.isParallelized: try: if len(ranges) != len(self._chunks): self._chunks.setObjectList([NodeChunk(self, range) for range in ranges]) for c in self._chunks: c.statusChanged.connect(self.globalStatusChanged) logging.debug(f"Created {len(self._chunks)} chunks for node: {self.name}") else: for chunk, range in zip(self._chunks, ranges): chunk.range = range except RuntimeError: # TODO: set node internal status to error logging.warning(f"Invalid Parallelization on node {self._name}") self._chunks.clear() else: if len(self._chunks) != 1: self._chunks.setObjectList([NodeChunk(self, desc.Range())]) self._chunks[0].statusChanged.connect(self.globalStatusChanged) else: self._chunks[0].range = desc.Range() self._chunksCreated = True # Update node status # TODO: update all chunks status? # TODO: update node status? # Emit signals for UI updates self.chunksChanged.emit() self.chunksCreatedChanged.emit() def createChunksFromCache(self): """ Create chunks when a node cache exists. """ try: # Get size from cache size = self._nodeStatus.fullSize self.setSize(size) ranges = self._nodeStatus.getChunkRanges() self.__createChunks(ranges) except Exception as e: logging.error(f"Failed to create chunks for {self.name}") self._chunks.clear() self._chunksCreated = False raise e def createChunks(self): """ Create chunks when computation is about to start. """ if self._chunksCreated: return if self.isInputNode: self._chunksCreated = True self.chunksChanged.emit() return # Grab current chunk information logging.debug(f"Creating chunks for node: {self.name}") try: size = self.evaluateSize() self.setSize(size) ranges = self.nodeDesc.parallelization.getRanges(self) self.__createChunks(ranges) except Exception as e: logging.error(f"Failed to create chunks for {self.name}: {e}") self._chunks.clear() self._chunksCreated = False raise e # Update status self._nodeStatus.setChunks(self._chunks) self.upgradeStatusFile() class BackdropNode(BaseNode): def __init__(self, nodeType: str, position=None, parent=None, uid=None, **kwargs): super().__init__(nodeType, position, parent=parent, uid=uid, **kwargs) self._chunksCreated = True if not self.nodeDesc: raise UnknownNodeTypeError(nodeType) self.packageName = self.nodeDesc.packageName for attrDesc in self.nodeDesc.internalInputs: self._internalAttributes.add(attributeFactory(attrDesc, kwargs.get(attrDesc.name, None), isOutput=False, node=self)) def _isBackdropNode(self) -> bool: return True def toDict(self): internalInputs = {k: v.getSerializedValue() for k, v in self._internalAttributes.objects.items()} return { 'nodeType': self.nodeType, 'position': self._position, 'parallelization': { 'blockSize': 0, 'size': 0, 'split': 0 }, 'uid': self._uid, 'internalInputs': {k: v for k, v in internalInputs.items() if v is not None}, } class CompatibilityIssue(Enum): """ Enum describing compatibility issues when deserializing a Node. """ UnknownIssue = 0 # unknown issue fallback UnknownNodeType = 1 # the node type has no corresponding description class VersionConflict = 2 # mismatch between node's description version and serialized node data DescriptionConflict = 3 # mismatch between node's description attributes and serialized node data UidConflict = 4 # mismatch between computed UIDs and UIDs stored in serialized node data PluginIssue = 5 # issue when loading the associated plugin class CompatibilityNode(BaseNode): """ Fallback BaseNode subclass to instantiate Nodes having compatibility issues with current type description. CompatibilityNode creates an 'empty-shell' exposing the deserialized node as-is, with all its inputs and precomputed outputs. """ def __init__(self, nodeType, nodeDict, position=None, issue=CompatibilityIssue.UnknownIssue, parent=None): super().__init__(nodeType, position, parent) self.issue = issue # Make a deepcopy of nodeDict to handle CompatibilityNode duplication # and be able to change modified inputs (see CompatibilityNode.toDict) self.nodeDict = copy.deepcopy(nodeDict) version = self.nodeDict.get("version") self.version = Version(version) if version else None self._inputs = self.nodeDict.get("inputs", {}) self._internalInputs = self.nodeDict.get("internalInputs", {}) self.outputs = self.nodeDict.get("outputs", {}) self._uid = self.nodeDict.get("uid", None) # Restore parallelization settings self.parallelization = self.nodeDict.get("parallelization", {}) self.splitCount = self.parallelization.get("split", 1) self.setSize(self.parallelization.get("size", 1)) # Create input attributes for attrName, value in self._inputs.items(): self._addAttribute(attrName, value, isOutput=False) # Create outputs attributes for attrName, value in self.outputs.items(): self._addAttribute(attrName, value, isOutput=True) # Create internal attributes for attrName, value in self._internalInputs.items(): self._addAttribute(attrName, value, isOutput=False, internalAttr=True) # Create NodeChunks matching serialized parallelization settings self._chunks.setObjectList([ NodeChunk(self, desc.Range(i, blockSize=self.parallelization.get("blockSize", 0))) for i in range(self.splitCount) ]) def _isCompatibilityNode(self): return True def _updateNodeSize(self): # Block the recompute of the node size for compatibility nodes pass @staticmethod def attributeDescFromValue(attrName, value, isOutput): """ Generate an attribute description (desc.Attribute) that best matches 'value'. Args: attrName (str): the name of the attribute value: the value of the attribute isOutput (bool): whether the attribute is an output Returns: desc.Attribute: the generated attribute description """ params = { "name": attrName, "label": attrName, "description": "Incompatible parameter", "value": value, "invalidate": False, "commandLineGroup": "incompatible" } if isinstance(value, bool): return desc.BoolParam(**params) if isinstance(value, int): return desc.IntParam(range=None, **params) elif isinstance(value, float): return desc.FloatParam(range=None, **params) elif isinstance(value, str): if isOutput or os.path.isabs(value): return desc.File(**params) elif Attribute.isLinkExpression(value): # Do not consider link expression as a valid default desc value. # When the link expression is applied and transformed to an actual link, # the systems resets the value using `Attribute.resetToDefaultValue` to indicate # that this link expression has been handled. # If the link expression is stored as the default value, it will never be cleared, # leading to unexpected behavior where the link expression on a CompatibilityNode # could be evaluated several times and/or incorrectly. params["value"] = "" return desc.File(**params) else: return desc.StringParam(**params) # List/GroupAttribute: recursively build descriptions elif isinstance(value, (list, dict)): del params["value"] del params["invalidate"] attrDesc = None if isinstance(value, list): elt = value[0] if value else "" # Fallback: empty string value if list is empty eltDesc = CompatibilityNode.attributeDescFromValue("element", elt, isOutput) attrDesc = desc.ListAttribute(elementDesc=eltDesc, **params) elif isinstance(value, dict): items = [] for key, value in value.items(): eltDesc = CompatibilityNode.attributeDescFromValue(key, value, isOutput) items.append(eltDesc) attrDesc = desc.GroupAttribute(items=items, **params) # Override empty default value with attrDesc._value = value return attrDesc # Handle any other type of parameters as Strings return desc.StringParam(**params) @staticmethod def attributeDescFromName(refAttributes, name, value, strict=True): """ Try to find a matching attribute description in refAttributes for given attribute 'name' and 'value'. Args: refAttributes ([desc.Attribute]): reference Attributes to look for a description name (str): attribute's name value: attribute's value strict: strict test for the match (for instance, regarding a group with some parameter changes) Returns: desc.Attribute: an attribute description from refAttributes if a match is found, None otherwise. """ # from original node description based on attribute's name attrDesc = next((d for d in refAttributes if d.name == name), None) if attrDesc is None: return None # We have found a description, and we still need to # check if the value matches the attribute description. # # If it is a serialized link expression (no proper value to set/evaluate) if Attribute.isLinkExpression(value): return attrDesc # If it is a GroupAttribute, all the attributes within the group should be matched # individually so that links can correctly be evaluated. if isinstance(attrDesc, desc.GroupAttribute): for k, v in value.items(): if CompatibilityNode.attributeDescFromName(attrDesc.items, k, v, strict=True) is None: return None return attrDesc # If it passes the 'matchDescription' test if attrDesc.matchDescription(value, strict): return attrDesc return None def _addAttribute(self, name, val, isOutput, internalAttr=False): """ Add a new attribute on this node. Args: name (str): the name of the attribute val: the attribute's value isOutput: whether the attribute is an output internalAttr: whether the attribute is internal Returns: bool: whether the attribute exists in the node description """ attrDesc = None if self.nodeDesc: if internalAttr: refAttrs = self.nodeDesc.internalInputs else: refAttrs = self.nodeDesc.outputs if isOutput else self.nodeDesc.inputs attrDesc = CompatibilityNode.attributeDescFromName(refAttrs, name, val) matchDesc = attrDesc is not None if attrDesc is None: attrDesc = CompatibilityNode.attributeDescFromValue(name, val, isOutput) attribute = attributeFactory(attrDesc, val, isOutput, self) if internalAttr: self._internalAttributes.add(attribute) else: self._attributes.add(attribute) return matchDesc @property def issueDetails(self): if self.issue == CompatibilityIssue.UnknownNodeType: return f"Unknown node type: '{self.nodeType}'." elif self.issue == CompatibilityIssue.VersionConflict: version = self.nodeDict["version"] return f"Node version '{version}' conflicts with current version '{nodeVersion(self.nodeDesc)}'." elif self.issue == CompatibilityIssue.DescriptionConflict: return "Node attributes do not match node description." elif self.issue == CompatibilityIssue.UidConflict: return "Node UID differs from the expected one." else: return "Unknown error." @property def inputs(self): """ Get current node inputs, where links could differ from original serialized node data (i.e after node duplication) """ # if node has not been added to a graph, return serialized node inputs if not self.graph: return self._inputs return {k: v.getSerializedValue() for k, v in self._attributes.objects.items() if v.isInput} @property def internalInputs(self): """ Get current node's internal attributes """ if not self.graph: return self._internalInputs return {k: v.getSerializedValue() for k, v in self._internalAttributes.objects.items()} def toDict(self): """ Return the original serialized node that generated a compatibility issue. Serialized inputs are updated to handle instances that have been duplicated and might be connected to different nodes. """ # update inputs to get up-to-date connections self.nodeDict.update({"inputs": self.inputs}) # update position self.nodeDict.update({"position": self.position}) return self.nodeDict @property def canUpgrade(self): """ Return whether the node can be upgraded. This is the case when the underlying node type has a corresponding description. """ return self.nodeDesc is not None def upgrade(self): """ Return a new Node instance based on original node type with common inputs initialized. """ if not self.canUpgrade: raise NodeUpgradeError(self.name, "No matching node type") # inputs matching current type description commonInputs = [] for attrName, value in self._inputs.items(): if self.attributeDescFromName(self.nodeDesc.inputs, attrName, value, strict=False): # store attributes that could be used during node upgrade commonInputs.append(attrName) commonInternalAttributes = [] for attrName, value in self._internalInputs.items(): if self.attributeDescFromName(self.nodeDesc.internalInputs, attrName, value, strict=False): # store internal attributes that could be used during node upgrade commonInternalAttributes.append(attrName) node = Node(self.nodeType, position=self.position) # convert attributes from a list of tuples into a dict attrValues = {key: value for (key, value) in self.inputs.items()} intAttrValues = {key: value for (key, value) in self.internalInputs.items()} # Use upgrade method of the node description itself if available try: upgradedAttrValues = node.nodeDesc.upgradeAttributeValues(attrValues, self.version) except Exception as exc: logging.error(f"Error in the upgrade implementation of the node: {self.name}.\n{repr(exc)}") upgradedAttrValues = attrValues if not isinstance(upgradedAttrValues, dict): logging.error(f"Error in the upgrade implementation of the node: {self.name}. The return type is incorrect.") upgradedAttrValues = attrValues node.upgradeAttributeValues(upgradedAttrValues) node.upgradeInternalAttributeValues(intAttrValues) return node compatibilityIssue = Property(int, lambda self: self.issue.value, constant=True) canUpgrade = Property(bool, canUpgrade.fget, constant=True) issueDetails = Property(str, issueDetails.fget, constant=True) ================================================ FILE: meshroom/core/nodeFactory.py ================================================ import logging from typing import Any, Optional, Union from collections.abc import Iterable import meshroom.core from meshroom.core import Version, desc from meshroom.core.node import BackdropNode, CompatibilityIssue, CompatibilityNode, Node, Position def nodeFactory( nodeData: dict, name: Optional[str] = None, inTemplate: bool = False, expectedUid: Optional[str] = None, ) -> Union[Node, BackdropNode, CompatibilityNode]: """ Create a node instance by deserializing the given node data. If the serialized data matches the corresponding node type description, a Node instance is created. If any compatibility issue occurs, a NodeCompatibility instance is created instead. Args: nodeData: The serialized Node data. name: The node's name. inTemplate: True if the node is created as part of a graph template. expectedUid: The expected UID of the node within the context of a Graph. Returns: The created Node instance. """ return _NodeCreator(nodeData, name, inTemplate, expectedUid).create() def getNodeConstructor(nodeType: str, position: Optional[Position]=None, **kwargs) -> Union[BackdropNode, Node]: constructors = { "Backdrop": BackdropNode, } constructor = constructors.get(nodeType, Node) return constructor(nodeType, position=position, **kwargs) class _NodeCreator: def __init__( self, nodeData: dict, name: Optional[str] = None, inTemplate: bool = False, expectedUid: Optional[str] = None, ): self.nodeData = nodeData self.name = name self.inTemplate = inTemplate self.expectedUid = expectedUid self._normalizeNodeData() self.nodeType = self.nodeData["nodeType"] self.inputs = self.nodeData.get("inputs", {}) self.internalInputs = self.nodeData.get("internalInputs", {}) self.outputs = self.nodeData.get("outputs", {}) self.version = self.nodeData.get("version", None) self.position = Position(*self.nodeData.get("position", [])) self.uid = self.nodeData.get("uid", None) self.nodeDesc = None if meshroom.core.pluginManager.isRegistered(self.nodeType): self.nodeDesc = meshroom.core.pluginManager.getRegisteredNodePlugin(self.nodeType).nodeDescriptor def create(self) -> Union[Node, BackdropNode, CompatibilityNode]: compatibilityIssue = self._checkCompatibilityIssues() if compatibilityIssue: node = self._createCompatibilityNode(compatibilityIssue) node = self._tryUpgradeCompatibilityNode(node) else: node = self._createNode() return node def _normalizeNodeData(self): """Consistency fixes for backward compatibility with older serialized data.""" # Inputs were previously saved as "attributes". if "inputs" not in self.nodeData and "attributes" in self.nodeData: self.nodeData["inputs"] = self.nodeData["attributes"] del self.nodeData["attributes"] def _checkCompatibilityIssues(self) -> Optional[CompatibilityIssue]: if self.nodeDesc is None: if meshroom.core.pluginManager.belongsToPlugin(self.nodeType) is not None: return CompatibilityIssue.PluginIssue return CompatibilityIssue.UnknownNodeType if not self._checkUidCompatibility(): return CompatibilityIssue.UidConflict if not self._checkVersionCompatibility(): return CompatibilityIssue.VersionConflict if not self._checkDescriptionCompatibility(): return CompatibilityIssue.DescriptionConflict return None def _checkUidCompatibility(self) -> bool: return self.expectedUid is None or self.expectedUid == self.uid def _checkVersionCompatibility(self) -> bool: # Special case: a node with a version set to None indicates # that it has been created from the current version of the node type. nodeCreatedFromCurrentVersion = self.version is None if nodeCreatedFromCurrentVersion: return True nodeTypeCurrentVersion = meshroom.core.nodeVersion(self.nodeDesc) # If the node type has not current version information, assume compatibility. if nodeTypeCurrentVersion is None: return True return Version(self.version).major == Version(nodeTypeCurrentVersion).major def _checkDescriptionCompatibility(self) -> bool: # Only perform strict attribute name matching for non-template graphs, # since only non-default-value input attributes are serialized in templates. if not self.inTemplate: if not self._checkAttributesNamesMatchDescription(): return False return self._checkAttributesAreCompatibleWithDescription() def _checkAttributesNamesMatchDescription(self) -> bool: return ( self._checkInputAttributesNames() and self._checkOutputAttributesNames() and self._checkInternalAttributesNames() ) def _checkAttributesAreCompatibleWithDescription(self) -> bool: return ( self._checkAttributesCompatibility(self.nodeDesc.inputs, self.inputs) and self._checkAttributesCompatibility(self.nodeDesc.internalInputs, self.internalInputs) and self._checkAttributesCompatibility(self.nodeDesc.outputs, self.outputs) ) def _checkInputAttributesNames(self) -> bool: def serializedInput(attr: desc.Attribute) -> bool: """ Filter that excludes not-serialized desc input attributes. """ if isinstance(attr, desc.PushButtonParam): # PushButtonParam are not serialized has they do not hold a value. return False return True refAttributes = filter(serializedInput, self.nodeDesc.inputs) return self._checkAttributesNamesStrictlyMatch(refAttributes, self.inputs) def _checkOutputAttributesNames(self) -> bool: def serializedOutput(attr: desc.Attribute) -> bool: """ Filter that excludes not-serialized desc output attributes. """ if attr.isDynamicValue: # Dynamic outputs values are not serialized with the node, # as their value is written in the computed output data. return False return True refAttributes = filter(serializedOutput, self.nodeDesc.outputs) return self._checkAttributesNamesStrictlyMatch(refAttributes, self.outputs) def _checkInternalAttributesNames(self) -> bool: invalidatingDescAttributes = [attr.name for attr in self.nodeDesc.internalInputs if attr.invalidate] return all(attr in self.internalInputs.keys() for attr in invalidatingDescAttributes) def _checkAttributesNamesStrictlyMatch( self, descAttributes: Iterable[desc.Attribute], attributesDict: dict[str, Any] ) -> bool: refNames = sorted([attr.name for attr in descAttributes]) attrNames = sorted(attributesDict.keys()) return refNames == attrNames def _checkAttributesCompatibility( self, descAttributes: list[desc.Attribute], attributesDict: dict[str, Any] ) -> bool: return all( CompatibilityNode.attributeDescFromName(descAttributes, attrName, value) is not None for attrName, value in attributesDict.items() ) def _createNode(self) -> Union[BackdropNode, Node]: logging.info(f"Creating node '{self.name}'") # TODO: user inputs/outputs may conflicts with internal names (like logLevel, position, uid) # The line below can cause UI issues but at least prevent crashes internalInputs = {k: v for k, v in self.internalInputs.items() if k not in self.inputs.keys()} return getNodeConstructor( self.nodeType, position=self.position, uid=self.uid, **self.inputs, **internalInputs, **self.outputs, ) def _createCompatibilityNode(self, compatibilityIssue) -> CompatibilityNode: logging.warning(f"Compatibility issue detected for node '{self.name}': {compatibilityIssue.name}") return CompatibilityNode( self.nodeType, self.nodeData, position=self.position, issue=compatibilityIssue ) def _tryUpgradeCompatibilityNode(self, node: CompatibilityNode) -> Union[Node, CompatibilityNode]: """Handle possible upgrades of CompatibilityNodes, when no computed data is associated to the Node.""" if node.issue == CompatibilityIssue.UnknownNodeType: return node # Nodes in templates are not meant to hold computation data. if self.inTemplate: logging.warning(f"Compatibility issue in template: performing automatic upgrade on '{self.name}'") return node.upgrade() # Backward compatibility: "uid" was not serialized. if not self.uid: logging.warning(f"No serialized output data: performing automatic upgrade on '{self.name}'") return node.upgrade() return node ================================================ FILE: meshroom/core/plugins.py ================================================ from __future__ import annotations import glob import importlib import json import logging import os import re import sys from enum import Enum from inspect import getfile from pathlib import Path from meshroom.common import BaseObject from meshroom.core import desc from meshroom.core.desc.attribute import ValueTypeErrors from meshroom.core.desc.node import _MESHROOM_ROOT, _MESHROOM_COMPUTE_DEPS def validateNodeDesc(nodeDesc: desc.BaseNode) -> list[tuple[str, ValueTypeErrors]]: """ Check that the node has a valid description before being loaded. For the description to be valid, the default value of every parameter needs to correspond to the type of the parameter. An empty returned list means that every parameter is valid, and so is the node's description. If it is not valid, the returned list contains the names of the invalid parameters. In case of nested parameters (parameters in groups or lists, for example), the name of the parameter follows the name of the parent attributes. For example, if the attribute "x", contained in group "group", is invalid, then it will be added to the list as "group:x". Args: nodeDesc: Description of the node. Returns: errors: The list of invalid parameters if there are any, empty list otherwise. """ errors = [] for param in nodeDesc.inputs: errMsg, errType = param.checkValueTypes() if errMsg: errors.append((errMsg, errType)) for param in nodeDesc.outputs: if param.value is None: if issubclass(nodeDesc, desc.InputNode): errors.append((f"{param.name}", ValueTypeErrors.DYNAMIC_OUTPUT)) continue errMsg, errType = param.checkValueTypes() if errMsg: errors.append((errMsg, errType)) return errors def formatNodeDescriptionErrorMessage(error: tuple[str, ValueTypeErrors]) -> str: """ Format a node description error message from a tuple containing the error message (name of the attribute) and type. Args: error: Tuple containing the name of the parameter that was rejected, and the type of the error. Returns: str: Formatted error message. """ errMsg, errType = error if errType == ValueTypeErrors.TYPE: return f"'value': Invalid type for parameter '{errMsg}'." if errType == ValueTypeErrors.RANGE: return f"'range': Invalid range value for parameter '{errMsg}'." if errType == ValueTypeErrors.DYNAMIC_OUTPUT: return f"'value': Unsupported dynamic output for parameter '{errMsg}'." return f"Unknown error for parameter '{errMsg}'." class ProcessEnvType(Enum): """ Supported process environments. """ DIRTREE = "dirtree", REZ = "rez" class ProcessEnv(BaseObject): """ Describes the environment required by a node's process. Args: folder: the source folder for the process. configEnv: the dictionary containing the environment variables defined in a configuration file for the process to run. envType: (optional) the type of process environment. uri: (optional) the Unique Resource Identifier to activate the environment. """ def __init__(self, folder: str, configEnv: dict[str, str], envType: ProcessEnvType = ProcessEnvType.DIRTREE, uri: str = ""): super().__init__() self._folder: str = folder self._configEnv: dict[str: str] = configEnv self._processEnvType: ProcessEnvType = envType self.uri: str = uri self._env: dict = None def getEnvDict(self) -> dict: """ Return the environment dictionary if it has been modified, None otherwise. """ return self._env def getCommandPrefix(self) -> str: """ Return the prefix to the command line that will be executed by the process. """ return "" def getCommandSuffix(self) -> str: """ Return the suffix to the command line that will be executed by the process. """ return "" class DirTreeProcessEnv(ProcessEnv): """ """ def __init__(self, folder: str, configEnv: dict[str: str]): super().__init__(folder, configEnv, envType=ProcessEnvType.DIRTREE) # If there is a virtual environment, it is expected to be named "venv". # Beside the virtual environment, a standard "bin"/"lib"/"lib64" hierarchy at # the top level of the plugin folder is expected. venvFolder = Path(folder, "venv") # Find all the libs that are not directly at the "lib*"-level envLibPaths = glob.glob(f'{folder}/lib*/python[0-9].[0-9]*/site-packages', recursive=False) venvLibPaths = glob.glob(f'{venvFolder}/lib*/python[0-9].[0-9]*/site-packages', recursive=False) self.binPaths: list = [str(Path(folder, "bin")), str(Path(venvFolder, "bin"))] self.libPaths: list = [str(Path(folder, "lib")), str(Path(folder, "lib64")), str(Path(venvFolder, "lib")), str(Path(venvFolder, "lib64"))] self.pythonPaths: list = [str(Path(folder)), str(Path(venvFolder))] + \ self.binPaths + envLibPaths + venvLibPaths if sys.platform == "win32": # For Windows platforms, try and include the content of the virtual env if it exists # The virtual env is expected to be named "venv" venvLibPath = Path(venvFolder, "Lib", "site-packages") if venvLibPath.exists(): self.pythonPaths.append(venvLibPath.as_posix()) else: # For Linux platforms, lib paths may need to be discovered recursively to be properly # added to LD_LIBRARY_PATH extraLibPaths = [] regex = re.compile(r"^lib(\d{2})?$") for envPath in envLibPaths + venvLibPaths: for path, directories, _ in os.walk(envPath): for directory in directories: if re.match(regex, directory): extraLibPaths.append(os.path.join(path, directory)) self.libPaths = self.libPaths + extraLibPaths # Setup the environment dictionary self._env = os.environ.copy() self._env["PYTHONPATH"] = os.pathsep.join( [f"{_MESHROOM_ROOT}"] + self.pythonPaths + [os.getenv('PYTHONPATH', '')]) self._env["LD_LIBRARY_PATH"] = f"{os.pathsep.join(self.libPaths)}{os.pathsep}{os.getenv('LD_LIBRARY_PATH', '')}" self._env["PATH"] = f"{os.pathsep.join(self.binPaths)}{os.pathsep}{os.getenv('PATH', '')}" for k, val in self._configEnv.items(): # Preserve user-defined environment variables: # manually set environment variable values take precedence over config file defaults. if k in self._env: continue self._env[k] = val class RezProcessEnv(ProcessEnv): """ """ REZ_DELIMITER_PATTERN = re.compile(r"-|==|>=|>|<=|<") def __init__(self, folder: str, configEnv: dict[str: str], uri: str = ""): if not uri: raise RuntimeError("Missing name of the Rez environment needs to be provided.") super().__init__(folder, configEnv, envType=ProcessEnvType.REZ, uri=uri) def resolveRezSubrequires(self) -> list[str]: """ Return the list of packages defined for the node execution. These execution packages are named subrequires. Note: If a package does not have a version number, the version is aligned with the main Meshroom environment (if this package is defined). """ subrequires = os.environ.get(f"{self.uri.upper()}_SUBREQUIRES", "").split(os.pathsep) if not subrequires: return [] packages = [] # Packages that are resolved in the current environment currentEnvPackages = [] resolvedVersions = {} if "REZ_USED_RESOLVE" in os.environ: resolvedPackages = os.getenv("REZ_USED_RESOLVE", "").split() for package in resolvedPackages: if package.startswith("~"): continue currentEnvPackages.append(package) name, version = self.REZ_DELIMITER_PATTERN.split(package, maxsplit=1) resolvedVersions[name] = version logging.debug("Packages in the current environment: " + ", ".join(currentEnvPackages)) # Take packages with the set versions for those which have one, and try to take packages # in the current environment (if they are resolved in it) for package in subrequires: packageTuple = self.REZ_DELIMITER_PATTERN.split(package, maxsplit=1) if len(packageTuple) == 1: # Only the package name in the subrequires. # Search for a corresponding version in the parent environment. packageName = packageTuple[0] parentResolvedVersion = resolvedVersions.get(packageName) if parentResolvedVersion: packages.append(f"{packageName}=={parentResolvedVersion}") else: packages.append(package) elif len(packageTuple) == 2: # The subrequires ask for a specific version packages.append(package) def extractPackageName(packageString: str) -> str: return self.REZ_DELIMITER_PATTERN.split(packageString, maxsplit=1)[0] packageNames = [extractPackageName(package) for package in packages] for package in _MESHROOM_COMPUTE_DEPS: # For packages that are required by meshroom_compute, do not specify any version # or align it with Meshroom's: the version will be found during the resolution of # the environment based on the other packages. # If any of these packages is already part of the environment a plugin's dependency, # do not add it if package not in packageNames: packages.append(package) logging.debug("Packages for the execution environment: " + ", ".join(packages)) return packages def getCommandPrefix(self): # TODO: make Windows-compatible # Use the PYTHONPATH from the subrequires' environment (which will only be resolved once # inside the execution environment) and add MESHROOM_ROOT and the plugin's folder itself # to it pythonPaths = f"{os.pathsep.join(['$PYTHONPATH', f'{_MESHROOM_ROOT}', f'{self._folder}'])}" return f"rez env {' '.join(self.resolveRezSubrequires())} -c 'PYTHONPATH={pythonPaths} " def getCommandSuffix(self): return "'" def processEnvFactory(folder: str, configEnv: dict[str: str], envType: str = "dirtree", uri: str = "") -> ProcessEnv: if envType == "dirtree": return DirTreeProcessEnv(folder, configEnv) return RezProcessEnv(folder, configEnv, uri=uri) class NodePluginStatus(Enum): """ Loading status for NodePlugin objects. """ NOT_LOADED = 0 # The node plugin exists but is not loaded and cannot be used (not registered) LOADED = 1 # The node plugin is currently loaded and functional (it has been registered) DESC_ERROR = 2 # The node plugin exists but has an invalid description LOADING_ERROR = 3 # The node plugin exists and is valid but could not be successfully registered ERROR = 4 # Error when importing the node plugin from its module class Plugin(BaseObject): """ A collection of node plugins. Members: name: the name of the plugin (e.g. name of the Python module containing the node plugins) path: the absolute path of the plugin nodePlugins: dictionary mapping the name of a node plugin contained in the plugin to its corresponding NodePlugin object templates: dictionary mapping the name of templates (.mg files) 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 processEnv: the environment required for the nodes' processes to be correctly executed """ def __init__(self, name: str, path: str): super().__init__() self._name: str = name self._path: str = path self._nodePlugins: dict[str: NodePlugin] = {} self._templates: dict[str: str] = {} self._configEnv: dict[str: str] = {} self._configFullEnv: dict[str: str] = {} self._processEnv: ProcessEnv = ProcessEnv(path, self._configEnv) self.loadTemplates() self.loadConfig() @property def name(self): """ Return the name of the plugin. """ return self._name @property def path(self): """ Return the absolute path of the plugin. """ return self._path @property def nodes(self): """ Return the dictionary containing the NodePlugin objects associated to the plugin. """ return self._nodePlugins @property def templates(self): """ Return the list of templates associated to the plugin. """ return self._templates @property def processEnv(self): """ Return the environment required to successfully execute processes. """ return self._processEnv @processEnv.setter def processEnv(self, processEnv: ProcessEnv): """ Set the environment required to successfully execute processes. """ self._processEnv = processEnv @property def configEnv(self): """ Return the dictionary containing the environment variables and their values provided in the plugin's configuration file. """ return self._configEnv @property def configFullEnv(self): """ Return the fusion of the os.environ dictionary with the configEnv dictionary. """ return self._configFullEnv def addNodePlugin(self, nodePlugin: NodePlugin): """ Add a node plugin to the current plugin object and assign it as its containing plugin. The node plugin is added to the dictionary of node plugins with the name of the node descriptor as its key. Args: nodePlugin: the NodePlugin object to add to the Plugin. """ self._nodePlugins[nodePlugin.nodeDescriptor.__name__] = nodePlugin nodePlugin.plugin = self def removeNodePlugin(self, name: str): """ Remove a node plugin from the current plugin object and delete any container relationship. Args: name: the name of the NodePlugin to remove. """ if name in self._nodePlugins: self._nodePlugins[name].plugin = None del self._nodePlugins[name] else: logging.warning(f"Node plugin {name} is not part of the plugin {self.name}.") def loadTemplates(self): """ Load all the pipeline templates that are available within the plugin folder. Whenever this method is called, the list of templates for the plugin is cleared, 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) def loadConfig(self): """ Load the plugin's configuration file if it exists and saves all its environment variables and their values, if they are valid. The configuration file is expected to be named "config.json", located at the top-level of the plugin. """ try: with open(os.path.join(self.path, "config.json")) as config: content = json.load(config) for entry in content: # An entry is expected to be formatted as follows: # { "key": "key_of_var", "type": "type_of_value", "value": "var_value" } # If "type" is not provided, it is assumed to be "string" k = entry.get("key", None) t = entry.get("type", None) val = entry.get("value", None) if not k or not val: logging.warning(f"Invalid entry in configuration file for {self.name}: {entry}.") continue if t == "path": if os.path.isabs(val): resolvedPath = Path(val).resolve() else: resolvedPath = Path(os.path.join(self.path, val)).resolve() if resolvedPath.exists(): val = resolvedPath.as_posix() else: logging.debug(f"{k}: {resolvedPath.as_posix()} does not exist " f"(path before resolution: {val}).") self._configEnv[k] = str(val) except FileNotFoundError: logging.debug(f"No configuration file 'config.json' was found for {self.name}.") except json.JSONDecodeError as err: logging.error(f"Malformed JSON in the configuration file for {self.name}: {err}") except IOError as err: logging.error(f"Error while accessing the configuration file for {self.name}: {err}") # If both dictionaries have identical keys, os.environ overwrites existing values from _configEnv self._configFullEnv = self._configEnv | os.environ def containsNodePlugin(self, name: str) -> bool: """ Return whether the node plugin "name" is part of the plugin, independently from its status. Args: name: the name of the node plugin to be checked. """ return name in self._nodePlugins class NodePlugin(BaseObject): """ Based on a node description, a NodePlugin represents a loadable node. Members: plugin: the Plugin object that contains this node plugin path: absolute path to the file containing the node's description nodeDescriptor: the description of the node status: the loading status on the node plugin errors: the list of errors (if there are any) when validating the description of the node or attempting to load it processEnv: the environment required for the node plugin's process. It can either be specific to this node plugin, or be common for all the node plugins within the plugin timestamp: the timestamp corresponding to the last time the node description's file has been modified """ def __init__(self, nodeDesc: desc.BaseNode, plugin: Plugin = None): super().__init__() self.plugin: Plugin = plugin self.path: str = Path(getfile(nodeDesc)).resolve().as_posix() self.nodeDescriptor: desc.BaseNode = nodeDesc self.nodeDescriptor.plugin = self self.status: NodePluginStatus = NodePluginStatus.NOT_LOADED self.errors: list[tuple[str, ValueTypeErrors]] = validateNodeDesc(nodeDesc) if self.errors: self.status = NodePluginStatus.DESC_ERROR self._processEnv = None self._timestamp = os.path.getmtime(self.path) def reload(self) -> bool: """ Reload the node plugin and update its status accordingly. If the timestamp of the node plugin's path has not changed since the last time the plugin has been loaded, then nothing will happen. Returns: bool: True if the node plugin has successfully been reloaded (i.e. there was no error, and some changes were made since its last loading), False otherwise. """ timestamp = 0.0 try: timestamp = os.path.getmtime(self.path) except FileNotFoundError: self.status = NodePluginStatus.ERROR logging.error(f"[Reload] {self.nodeDescriptor.__name__}: The path at {self.path} was not " f"not found.") return False if self._timestamp == timestamp: logging.info(f"[Reload] {self.nodeDescriptor.__name__}: Not reloading. The node description " f"at {self.path} has not been modified since the last load.") return False try: updated = importlib.reload(sys.modules.get(self.nodeDescriptor.__module__)) except Exception as exc: logging.error(f"[Reload] {self.nodeDescriptor.__name__}: {exc} ({type(exc).__name__})") self.status = NodePluginStatus.DESC_ERROR return False descriptor = getattr(updated, self.nodeDescriptor.__name__) if not descriptor: self.status = NodePluginStatus.ERROR logging.error(f"[Reload] {self.nodeDescriptor.__name__}: The node description at {self.path} " f"was not found.") return False self.errors = validateNodeDesc(descriptor) if self.errors: self.status = NodePluginStatus.DESC_ERROR logging.error(f"[Reload] {self.nodeDescriptor.__name__}: The node description at {self.path} " f"has description errors.") return False self.nodeDescriptor = descriptor self.nodeDescriptor.plugin = self self._timestamp = timestamp self.status = NodePluginStatus.NOT_LOADED logging.info(f"[Reload] {self.nodeDescriptor.__name__}: Successful reloading.") return True @property def plugin(self): """ Return the Plugin object that contains this node plugin. If the node plugin has not been assigned to a plugin yet, this value will be set to None. """ return self._plugin @plugin.setter def plugin(self, plugin: Plugin): """ Assign this node plugin to a containing Plugin object. """ self._plugin = plugin @property def processEnv(self): """" Return the process environment that is specific to the node plugin if it has any. Otherwise, the Plugin's is returned. """ if self._processEnv: return self._processEnv if self.plugin: return self.plugin.processEnv return None @property def runtimeEnv(self) -> dict: """ Return the environment dictionary for the runtime. """ return self.processEnv.getEnvDict() @property def commandPrefix(self) -> str: """ Return the command prefix for the NodePlugin's execution. """ if not self.processEnv: return "" return self.processEnv.getCommandPrefix() @property def commandSuffix(self) -> str: """ Return the command suffix for the NodePlugin's execution. """ if not self.processEnv: return "" return self.processEnv.getCommandSuffix() @property def configFullEnv(self) -> dict[str: str]: """ Return the plugin's full environment dictionary. """ if not self.plugin: return {} return self.plugin.configFullEnv class NodePluginManager(BaseObject): """ Manager for all the loaded Plugin objects as well as the registered NodePlugin objects. Members: plugins: dictionary containing all the loaded Plugins, with their name as the key nodePlugins: dictionary containing all the NodePlugins that have been registered (a NodePlugin may exist without having been registered) with their name as the key """ def __init__(self): super().__init__() self._plugins: dict[str: Plugin] = {} # loaded plugins self._nodePlugins: dict[str: NodePlugin] = {} # registered node plugins def isRegistered(self, name: str) -> bool: """ Return whether the node plugin has been registered already. Args: name: the name of the node plugin whose registration needs to be checked. """ return name in self._nodePlugins def belongsToPlugin(self, name: str) -> Plugin: """ Check whether the node plugin belongs to a loaded plugin, independently from whether it has been registered or not. Args: name: the name of the node plugin that needs to be searched for across plugins. Returns: Plugin | None: the Plugin the node belongs to if it exists, None otherwise. """ for plugin in self._plugins.values(): if plugin.containsNodePlugin(name): return plugin return None def getPlugins(self) -> dict[str: Plugin]: """ Return a dictionary containing all the loaded Plugins, with {key, value} = {name, Plugin}. """ return self._plugins def getPlugin(self, name: str) -> Plugin: """ Return the loaded Plugin object named "name". Args: name: the name of the Plugin, used upon its loading. Returns: Plugin | None: the loaded Plugin object if it exists, None otherwise. """ if name in self._plugins: return self._plugins[name] return None def addPlugin(self, plugin: Plugin, registerNodePlugins: bool = True): """ Load a Plugin object. Args: plugin: the Plugin to load and add to the list of loaded plugins. registerNodePlugins: True if all the NodePlugins from the plugin should be registered at the same time the plugin is being loaded. Otherwise, the NodePlugins will have to be registered at a later occasion. """ if not self.getPlugin(plugin.name): self._plugins[plugin.name] = plugin if registerNodePlugins: for node in plugin.nodes: self.registerNode(plugin.nodes[node]) def removePlugin(self, plugin: Plugin, unregisterNodePlugins: bool = True): """ Remove a loaded Plugin object. Args: plugin: the Plugin to remove from the list of loaded plugins. unregisterNodePlugins: True if all the nodes from the plugin should be unregistered (if they are registered) at the same time as the plugin is unloaded. Otherwise, the registered NodePlugins will remain while the Plugin itself will be unloaded. """ if self.getPlugin(plugin.name): if unregisterNodePlugins: for node in plugin.nodes.values(): self.unregisterNode(node) del self._plugins[plugin.name] def getRegisteredNodePlugins(self) -> dict[str: NodePlugin]: """ Return a dictionary containing all the registered NodePlugins, with {key, value} = {name, NodePlugin}. """ return self._nodePlugins def getRegisteredNodePlugin(self, name: str) -> NodePlugin: """ Return the NodePlugin object that has been registered under the name "name" if it exists. Args: name: the name of the NodePlugin used for its registration. Returns: NodePlugin | None: the loaded NodePlugin object if it exists, None otherwise. """ if self.isRegistered(name): return self._nodePlugins[name] return None def registerNode(self, nodePlugin: NodePlugin): """ Register a node plugin. A registered node plugin will become instantiable. If it is already registered, or if there is an issue with the node description, the node plugin will not be registered and its status will be updated. Args: nodePlugin: the node plugin to register. """ name = nodePlugin.nodeDescriptor.__name__ if not self.isRegistered(name) and nodePlugin.status not in (NodePluginStatus.DESC_ERROR, NodePluginStatus.ERROR): try: self._nodePlugins[name] = nodePlugin nodePlugin.status = NodePluginStatus.LOADED except Exception as exc: logging.error(f"NodePlugin {name} could not be loaded: {exc}") nodePlugin.status = NodePluginStatus.LOADING_ERROR def unregisterNode(self, nodePlugin: NodePlugin): """ Unregister a node plugin. When unregistered, a node plugin cannot be instantiated anymore. If it is not registered already, nothing happens. Args: nodePlugin: the node plugin to unregister. """ name = nodePlugin.nodeDescriptor.__name__ if self.isRegistered(name): if nodePlugin.status != NodePluginStatus.LOADED: logging.warning(f"NodePlugin {name} is registered but is not correctly loaded.") else: nodePlugin.status = NodePluginStatus.NOT_LOADED del self._nodePlugins[name] ================================================ FILE: meshroom/core/stats.py ================================================ from collections import defaultdict import os import platform import time import threading import xml.etree.ElementTree as ET import subprocess import logging import psutil def bytes2human(n): """ >>> bytes2human(10000) '9.8 K/s' >>> bytes2human(100001221) '95.4 M/s' """ symbols = ('K', 'M', 'G', 'T', 'P', 'E', 'Z', 'Y') prefix = {} for i, s in enumerate(symbols): prefix[s] = 1 << (i + 1) * 10 for s in reversed(symbols): if n >= prefix[s]: value = float(n) / prefix[s] return f'{value:.2f} {s}' return f'{n:.2f} B' class ComputerStatistics: def __init__(self): self.nbCores = 0 self.cpuFreq = 0 self.ramTotal = 0 self.ramAvailable = 0 # GB self.vramAvailable = 0 # GB self.swapAvailable = 0 self.gpuMemoryTotal = 0 self.gpuName = '' self.curves = defaultdict(list) self.nvidia_smi = None self._isInit = False def initOnFirstTime(self): if self._isInit: return self._isInit = True self.cpuFreq = psutil.cpu_freq().max self.ramTotal = psutil.virtual_memory().total / (1024*1024*1024) if platform.system() == "Windows": import shutil # If the platform is Windows and nvidia-smi self.nvidia_smi = shutil.which('nvidia-smi') if self.nvidia_smi is None: # Could not be found from the environment path, # try to find it from system drive with default installation path default_nvidia_smi = f"{os.environ['systemdrive']}\\Program Files\\NVIDIA Corporation\\NVSMI\\nvidia-smi.exe" if os.path.isfile(default_nvidia_smi): self.nvidia_smi = default_nvidia_smi else: self.nvidia_smi = "nvidia-smi" def _addKV(self, k, v): if isinstance(v, tuple): for ki, vi in v._asdict().items(): self._addKV(k + '.' + ki, vi) elif isinstance(v, list): for ki, vi in enumerate(v): self._addKV(k + '.' + str(ki), vi) else: self.curves[k].append(v) def update(self): try: self.initOnFirstTime() # Interval=None => non-blocking (percentage since last call) self._addKV('cpuUsage', psutil.cpu_percent(percpu=True)) self._addKV('ramUsage', psutil.virtual_memory().percent) self._addKV('swapUsage', psutil.swap_memory().percent) self._addKV('vramUsage', 0) self._addKV('ioCounters', psutil.disk_io_counters()) self.updateGpu() except Exception as exc: logging.debug(f'Failed to get statistics: "{exc}".') def updateGpu(self): if not self.nvidia_smi: return try: p = subprocess.Popen([self.nvidia_smi, "-q", "-x"], stdout=subprocess.PIPE, stderr=subprocess.PIPE) xmlGpu, stdError = p.communicate(timeout=10) # 10 seconds smiTree = ET.fromstring(xmlGpu) gpuTree = smiTree.find('gpu') try: self.gpuName = gpuTree.find('product_name').text except Exception as exc: logging.debug(f'Failed to get gpuName: "{exc}".') pass try: gpuMemoryUsed = gpuTree.find('fb_memory_usage').find('used').text.split(" ")[0] self._addKV('gpuMemoryUsed', gpuMemoryUsed) except Exception as exc: logging.debug(f'Failed to get gpuMemoryUsed: "{exc}".') pass try: self.gpuMemoryTotal = gpuTree.find('fb_memory_usage').find('total').text.split(" ")[0] except Exception as exc: logging.debug(f'Failed to get gpuMemoryTotal: "{exc}".') pass try: gpuUsed = gpuTree.find('utilization').find('gpu_util').text.split(" ")[0] self._addKV('gpuUsed', gpuUsed) except Exception as exc: logging.debug(f'Failed to get gpuUsed: "{exc}".') pass try: gpuTemperature = gpuTree.find('temperature').find('gpu_temp').text.split(" ")[0] self._addKV('gpuTemperature', gpuTemperature) except Exception as exc: logging.debug(f'Failed to get gpuTemperature: "{exc}".') pass except subprocess.TimeoutExpired as exp: logging.debug(f'Timeout when retrieving information from nvidia_smi: "{exp}".') p.kill() outs, errs = p.communicate() return except Exception as exc: logging.debug(f'Failed to get information from nvidia_smi: "{exc}".') return def toDict(self): return self.__dict__ def fromDict(self, d): for k, v in d.items(): setattr(self, k, v) class ProcStatistics: staticKeys = [ 'pid', 'nice', 'cpu_times', 'create_time', 'environ', 'ionice', # 'gids', # 'uids', 'cpu_num', 'cwd', 'cmdline', 'cpu_affinity', # 'ppid', # 'name', # 'exe', # 'terminal', 'username', ] dynamicKeys = [ # 'memory_full_info', # 'connections', 'cpu_percent', # 'open_files', 'memory_info', 'memory_percent', 'threads', 'num_threads', # 'memory_maps', 'status', # 'num_fds', # The number of file descriptors currently opened by this process (non cumulative) - N/A on Windows # 'io_counters', # The number and bytes read/write by the process - N/A on some platforms 'num_ctx_switches', ] def __init__(self): self.iterIndex = 0 self.lastIterIndexWithFiles = -1 self.duration = 0 # computation time set at the end of the execution self.curves = defaultdict(list) self.openFiles = {} def _addKV(self, k, v): if isinstance(v, tuple): for ki, vi in v._asdict().items(): self._addKV(k + '.' + ki, vi) elif isinstance(v, list): for ki, vi in enumerate(v): self._addKV(k + '.' + str(ki), vi) else: self.curves[k].append(v) def update(self, proc): ''' proc: psutil.Process object ''' data = proc.as_dict(self.dynamicKeys) for k, v in data.items(): self._addKV(k, v) # Note: Do not collect stats about open files for now, # as there is bug in psutil-5.7.2 on Windows which crashes the application. # https://github.com/giampaolo/psutil/issues/1763 # # files = [f.path for f in proc.open_files()] # if self.lastIterIndexWithFiles != -1: # if set(files) != set(self.openFiles[self.lastIterIndexWithFiles]): # self.openFiles[self.iterIndex] = files # self.lastIterIndexWithFiles = self.iterIndex # elif files: # self.openFiles[self.iterIndex] = files # self.lastIterIndexWithFiles = self.iterIndex self.iterIndex += 1 def toDict(self): return { 'duration': self.duration, 'curves': self.curves, 'openFiles': self.openFiles, } def fromDict(self, d): self.duration = d.get('duration', 0) self.curves = d.get('curves', defaultdict(list)) self.openFiles = d.get('openFiles', {}) class Statistics: """ """ fileVersion = 2.0 def __init__(self, maxPoints=100): self.computer = ComputerStatistics() self.process = ProcStatistics() self.times = [] self.interval = 1 # refresh interval in seconds self.maxPoints = maxPoints # maximum number of points to keep def _filterDataPoints(self, keepEveryN): """ Filter data points to keep every Nth point. """ # Filter times self.times = self.times[::keepEveryN] # Filter computer curves for key in self.computer.curves: self.computer.curves[key] = self.computer.curves[key][::keepEveryN] # Filter process curves for key in self.process.curves: self.process.curves[key] = self.process.curves[key][::keepEveryN] def update(self, proc): ''' proc: psutil.Process object ''' if proc is None or not proc.is_running(): return False self.times.append(time.time()) self.computer.update() self.process.update(proc) # Check if we exceeded max points and need to adjust interval if len(self.times) > self.maxPoints: # Calculate new interval (double it) newInterval = self.interval * 2 # Filter existing data to keep every other point self._filterDataPoints(2) # Update interval self.interval = newInterval logging.debug(f'Statistics: Increased interval to {self.interval}s to maintain max {self.maxPoints} points') return True def toDict(self): return { 'fileVersion': self.fileVersion, 'computer': self.computer.toDict(), 'process': self.process.toDict(), 'times': self.times, 'interval': self.interval, 'maxPoints': self.maxPoints, } def fromDict(self, d): version = d.get('fileVersion', 0.0) if version != self.fileVersion: logging.debug(f'Statistics: file version was {version} and the current version is {self.fileVersion}.') self.computer = ComputerStatistics() self.process = ProcStatistics() self.times = [] self.interval = d.get('interval', 1) self.maxPoints = d.get('maxPoints', 100) try: self.computer.fromDict(d.get('computer', {})) except Exception as exc: logging.debug(f'Failed while loading statistics: computer: "{exc}".') try: self.process.fromDict(d.get('process', {})) except Exception as exc: logging.debug(f'Failed while loading statistics: process: "{exc}".') try: self.times = d.get('times', []) except Exception as exc: logging.debug(f'Failed while loading statistics: times: "{exc}".') bytesPerGiga = 1024. * 1024. * 1024. class StatisticsThread(threading.Thread): def __init__(self, chunk): threading.Thread.__init__(self) self.chunk = chunk self.proc = psutil.Process() # by default current process pid self.statistics = chunk.statistics self._stopFlag = threading.Event() def updateStats(self): self.lastTime = time.time() if self.chunk.statistics.update(self.proc): self.chunk.saveStatistics() def run(self): try: while True: self.updateStats() if self._stopFlag.wait(self.statistics.interval): # stopFlag has been set # update stats one last time and exit main loop if self.proc.is_running(): self.updateStats() return except (KeyboardInterrupt, SystemError, GeneratorExit, psutil.NoSuchProcess): pass def stopRequest(self): """ Request the thread to exit as soon as possible. """ self._stopFlag.set() ================================================ FILE: meshroom/core/submitter.py ================================================ #!/usr/bin/env python import sys import logging import operator from enum import IntFlag, auto from typing import Optional from itertools import accumulate import meshroom from meshroom.common import BaseObject, Property logger = logging.getLogger("Submitter") logger.setLevel(logging.INFO) class SubmitterOptionsEnum(IntFlag): RETRIEVE = auto() # Can retrieve job (read job tasks, ...) INTERRUPT_JOB = auto() # Can interrupt RESUME_JOB = auto() # Can resume after interruption EDIT_TASKS = auto() # Can edit tasks ATTACH_JOB = auto() # Can attach a job that will execute after another job @classmethod def get(cls, option): if isinstance(option, str): # Try to cast to SubmitterOptionsEnum option = getattr(cls, option.upper(), None) elif isinstance(option, int): option = cls(option) if isinstance(option, cls): return option return 0 # SubmitterOptionsEnum.ALL = SubmitterOptionsEnum(SubmitterOptionsEnum._all_bits_) # _all_bits_ -> py 3.11 SubmitterOptionsEnum.ALL = list(accumulate(SubmitterOptionsEnum, operator.__ior__))[-1] class SubmitterOptions: def __init__(self, *args): self._options = 0 for option in args: self.addOption(option) def addOption(self, option): option = SubmitterOptionsEnum.get(option) self._options |= option def includes(self, option): option = SubmitterOptionsEnum.get(option) return self._options & option > 0 def __iter__(self): for o in SubmitterOptionsEnum: if self.includes(o): yield(o) def __repr__(self): if self._options == 0: return f"" if self._options == SubmitterOptionsEnum.ALL: return f"" return f"" class BaseSubmittedJob: """ Interface to manipulate the job via Meshroom """ def __init__(self, jobId, submitter): self.jid = jobId self.submitterName: str = submitter._name self.submitterOptions: SubmitterOptions = submitter._options def __repr__(self): return f"<{self.__class__.__name__} {self.jid}>" # Task actions # For all methods if If iteration is -1 then it kills all the tasks for the given node def stopChunkTask(self, node, iteration): """ This will kill one task. If iteration is -1 then it kills all the tasks for the given node """ if self.submitterOptions.includes(SubmitterOptionsEnum.INTERRUPT_JOB): raise NotImplementedError(f"'stopChunkTask' method must be implemented in subclasses") else: raise RuntimeError(f"Submitter {self.__class__.__name__} cannot interrupt the job") def skipChunkTask(self, node, iteration): """ This will kill one task """ if self.submitterOptions.includes(SubmitterOptionsEnum.INTERRUPT_JOB): raise NotImplementedError("'skipChunkTask' method must be implemented in subclasses") else: raise RuntimeError(f"Submitter {self.__class__.__name__} cannot interrupt the job") def restartChunkTask(self, node, iteration): """ This will kill one task """ if self.submitterOptions.includes(SubmitterOptionsEnum.RESUME_JOB): raise NotImplementedError("'restartChunkTask' method must be implemented in subclasses") else: raise RuntimeError(f"Submitter {self.__class__.__name__} cannot interrupt the job") # Job actions def pauseJob(self): """ This will pause the job : new tasks will not be processed """ if self.submitterOptions.includes(SubmitterOptionsEnum.INTERRUPT_JOB): raise NotImplementedError("'pauseJob' method must be implemented in subclasses") else: raise RuntimeError(f"Submitter {self.__class__.__name__} cannot interrupt the job") def resumeJob(self): """ This will unpause the job """ if self.submitterOptions.includes(SubmitterOptionsEnum.RESUME_JOB): raise NotImplementedError("'resumeJob' method must be implemented in subclasses") else: raise RuntimeError(f"Submitter {self.__class__.__name__} cannot interrupt the job") def interruptJob(self): """ This will interrupt the job (and kill running tasks) """ if self.submitterOptions.includes(SubmitterOptionsEnum.INTERRUPT_JOB): raise NotImplementedError("'interruptJob' method must be implemented in subclasses") else: raise RuntimeError(f"Submitter {self.__class__.__name__} cannot interrupt the job") def restartErrorTasks(self): if self.submitterOptions.includes(SubmitterOptionsEnum.RESUME_JOB): raise NotImplementedError("'restartErrorTasks' method must be implemented in subclasses") else: raise RuntimeError(f"Submitter {self.__class__.__name__} cannot restart the job") class JobManager(BaseObject): """ Central manager for all jobs """ def __init__(self): super().__init__() self._jobs = {} # jobId -> BaseSubmittedJob self._nodeToJob = {} # node uid -> Job def addJob(self, job: BaseSubmittedJob, nodes): jid = job.jid if jid not in self._jobs: self._jobs[jid] = job for node in nodes: nodeUid = node._uid self._nodeToJob[nodeUid] = jid # Update the node status file to store the job ID node.setJobId(jid, job.submitterName) def resetNodeJob(self, node): node._nodeStatus.jobInfo = {} if node._uid in self._nodeToJob: del self._nodeToJob[node._uid] def getJob(self, jobId: str) -> Optional[BaseSubmittedJob]: return self._jobs.get(jobId) def removeJob(self, jobId: str): with self._lock: if jobId in self._jobs: del self._jobs[jobId] def getNodeJob(self, node): nodeUid = node._uid jobId = self._nodeToJob.get(nodeUid) if jobId: return self.getJob(jobId) return None def getAllNodesUIDForJob(self, job): return [n for n, j in self._nodeToJob.items() if j == job.jid] def retreiveJob(self, submitter, jid) -> Optional[BaseSubmittedJob]: if not submitter._options.includes(SubmitterOptionsEnum.RETRIEVE): return None job = submitter.retrieveJob(jid) return job # Global instance that manages submitted jobs jobManager = JobManager() class BaseSubmitter(BaseObject): _options: SubmitterOptions = SubmitterOptions() _name = "" def __init__(self, parent=None): if not self._name: raise ValueError("Could not register submitter without name") super().__init__(parent) logger.info(f"Registered submitter {self._name} (options={self._options})") @property def name(self): return self._name def createJob(self, nodes, edges, filepath, submitLabel="{projectName}"): """ Submit the given graph Returns: bool: whether the submission succeeded """ raise NotImplementedError("'createJob' method must be implemented in subclasses") def createChunkTask(self, node, graphFile, **kwargs): if self._options.includes(SubmitterOptionsEnum.RESUME_JOB): raise NotImplementedError("'createChunkTask' method must be implemented in subclasses") else: raise RuntimeError(f"Submitter {self.name} cannot edit the job") def retrieveJob(self, jobId) -> BaseSubmittedJob: raise NotImplementedError("'retrieveJob' method must be implemented in subclasses") def submit(self, nodes, edges, filepath, submitLabel="{projectName}") -> BaseSubmittedJob: """ Submit the given graph Returns: bool: whether the submission succeeded """ job = self.createJob(nodes, edges, filepath, submitLabel) if not job: # Failed to create the job return None return job @staticmethod def killRunningJob(): """ Sometimes farms are automatically re-trying job once in case it was killed by a user who does not want their machine to be used. Unfortunately this means jobs will be launched twice even if they failed for a good reason. This function can be used to make sure the current job will not restart Note : the ERROR_NO_RETRY itself will not do anything. This function must be implemented on a case-by-case for each possible farm system """ sys.exit(meshroom.MeshroomExitStatus.ERROR_NO_RETRY) name = Property(str, lambda self: self._name, constant=True) ================================================ FILE: meshroom/core/taskManager.py ================================================ import traceback import logging from threading import Thread from PySide6.QtCore import QThread, QEventLoop, QTimer from enum import Enum import meshroom from meshroom.common import BaseObject, DictModel, Property, Signal, Slot from meshroom.core.node import Node, Status from meshroom.core.graph import Graph from meshroom.core.submitter import jobManager, BaseSubmittedJob import meshroom.core.graph class State(Enum): """ State of the Thread that is computing nodes """ IDLE = 0 RUNNING = 1 STOPPED = 2 DEAD = 3 ERROR = 4 class TaskThread(QThread): """ A thread with a pile of nodes to compute """ def __init__(self, manager): QThread.__init__(self) self._state = State.IDLE self._manager = manager self.forceCompute = False # Connect to manager's chunk creation handler self.createChunksSignal.connect(manager.createChunks) def isRunning(self): return self._state == State.RUNNING def waitForChunkCreation(self, node): if node._chunksCreated: return True loop = QEventLoop() # A timer is used to make sure we do not indefinitely block the taskManager timer = QTimer() timer.timeout.connect(loop.quit) timer.setSingleShot(True) timer.start(1*60*1000) # 1 min timeout # Connect to completion signal def onChunksCreated(createdNode): if createdNode == node: loop.quit() self._manager.chunksCreated.connect(onChunksCreated) try: # Start the event loop - will block until signal or timeout loop.exec() if not node._chunksCreated: logging.error(f"Timeout or failure creating chunks for {node.name}") return False return True finally: self._manager.chunksCreated.disconnect(onChunksCreated) timer.stop() def run(self): """ Consume compute tasks. """ self._state = State.RUNNING stopAndRestart = False for nId, node in enumerate(self._manager._nodesToProcess): if node not in self._manager._nodesToProcess: # Node was removed from the processing list continue # Skip already finished/running nodes or nodes in compatibility mode if node.isFinishedOrRunning() or node.isCompatibilityNode: continue # Request chunk creation if not already done if not node._chunksCreated: self.createChunksSignal.emit(node) # Wait for chunk creation to complete if not self.waitForChunkCreation(node): logging.error(f"Failed to create chunks for {node.name}, stopping the process") break else: node._updateNodeSize() # if a node does not exist anymore, node.chunks becomes a PySide property try: multiChunks = len(node.chunks) > 1 except TypeError: continue node.preprocess() for cId, chunk in enumerate(node.chunks): if chunk.isFinishedOrRunning() or not self.isRunning(): continue if self._manager.isChunkCancelled(chunk): continue _nodeName, _node, _nbNodes = node.nodeType, nId+1, len(self._manager._nodesToProcess) if multiChunks: _chunk, _nbChunks = cId+1, len(node.chunks) logging.info(f"[{_node}/{_nbNodes}]({_chunk}/{_nbChunks}) {_nodeName}") else: logging.info(f"[{_node}/{_nbNodes}] {_nodeName}") try: chunk.process(self.forceCompute) except Exception as exc: if chunk.isStopped(): stopAndRestart = True break else: logging.error(f"Error on node computation: {exc}") nodesToRemove, _ = self._manager._graph.dfsOnDiscover(startNodes=[node], reverse=True) # remove following nodes from the task queue for n in nodesToRemove[1:]: # exclude current node try: self._manager._nodesToProcess.remove(n) except ValueError: # Node already removed (for instance a global clear of _nodesToProcess) pass n.clearSubmittedChunks() node.postprocess() if stopAndRestart: break if stopAndRestart: self._state = State.STOPPED self._manager.restartRequested.emit() else: self._manager._nodesToProcess = [] self._state = State.DEAD # Signals and properties createChunksSignal = Signal(BaseObject) class TaskManager(BaseObject): """ Manage graph - local and external - computation tasks. """ def __init__(self, parent: BaseObject = None): super().__init__(parent) self._graph = None self._nodes = DictModel(keyAttrName='_name', parent=self) self._nodesToProcess = [] self._cancelledChunks = [] self._nodesExtern = [] # internal thread in which local tasks are executed self._thread = TaskThread(self) self._blockRestart = False self.restartRequested.connect(self.restart) def join(self): self._thread.wait() self._cancelledChunks = [] @Slot(BaseObject) def createChunks(self, node: Node): """ Create chunks on main process """ try: if not node._chunksCreated: node.createChunks() # Prepare all chunks node.initStatusOnCompute() self.chunksCreated.emit(node) except Exception as e: logging.error(f"Failed to create chunks for {node.name}: {e}") self.chunksCreated.emit(node) # Still emit to unblock waiting thread def isChunkCancelled(self, chunk): for i, ch in enumerate(self._cancelledChunks): if ch == chunk: del self._cancelledChunks[i] return True return False def requestBlockRestart(self): """ Block computing. Note: should only be used to completely stop computing. """ self._blockRestart = True def blockRestart(self): """ Avoid the automatic restart of computing. """ for node in self._nodesToProcess: chunkCount = 0 for chunk in node.chunks: if chunk.status.status in (Status.SUBMITTED, Status.ERROR): chunk.upgradeStatusTo(Status.NONE) chunkCount += 1 if chunkCount == len(node.chunks): self.removeNode(node, displayList=True) self._blockRestart = False self._nodesToProcess = [] self._cancelledChunks = [] self._thread._state = State.DEAD @Slot() def pauseProcess(self): if self._thread.isRunning(): self.join() for node in self._nodesToProcess: if node.getGlobalStatus() == Status.STOPPED: # Remove node from the computing list self.removeNode(node, displayList=False, processList=True) # Remove output nodes from display and computing lists outputNodes = node.getOutputNodes(recursive=True, dependenciesOnly=True) for n in outputNodes: if n.getGlobalStatus() in (Status.ERROR, Status.SUBMITTED): n.upgradeStatusTo(Status.NONE) self.removeNode(n, displayList=True, processList=True) @Slot() def restart(self): """ Restart computing when thread has been stopped. Note: this is done like this to avoid app freezing. """ # Make sure to wait the end of the current thread if self._thread.isRunning(): self.join() # Avoid restart if thread was globally stopped if self._blockRestart: self.blockRestart() return if self._thread._state != State.STOPPED: return for node in self._nodesToProcess: if node.getGlobalStatus() == Status.STOPPED: # Remove node from the computing list self.removeNode(node, displayList=False, processList=True) # Remove output nodes from display and computing lists outputNodes = node.getOutputNodes(recursive=True, dependenciesOnly=True) for n in outputNodes: if n.getGlobalStatus() in (Status.ERROR, Status.SUBMITTED): n.upgradeStatusTo(Status.NONE) self.removeNode(n, displayList=True, processList=True) # Start a new thread with the remaining nodes to compute self._thread = TaskThread(self) self._thread.start() def compute(self, graph: Graph = None, toNodes: list[Node] = None, forceCompute: bool = False, forceStatus: bool = False): """ Start graph computation, from root nodes to leaves - or nodes in 'toNodes' if specified. Computation tasks (NodeChunk) happen in a separate thread (see TaskThread). :param graph: the graph to consider. :param toNodes: specific leaves, all graph leaves if None. :param forceCompute: force the computation despite nodes status. :param forceStatus: force the computation even if some nodes are submitted externally. """ self._graph = graph self.updateNodes() self._cancelledChunks = [] if forceCompute: nodes, edges = graph.dfsOnFinish(startNodes=toNodes) self.checkCompatibilityNodes(graph, nodes, "COMPUTATION") # name of the context is important for QML self.checkDuplicates(nodes, "COMPUTATION") # name of the context is important for QML else: # Check dependencies of toNodes if not toNodes: toNodes = graph.getLeafNodes(dependenciesOnly=True) toNodes = list(toNodes) toNodes = [node for node in toNodes if not node.isBackdropNode] allReady = self.checkNodesDependencies(graph, toNodes, "COMPUTATION") # At this point, toNodes is a list # If it is empty, we raise an error to avoid passing through dfsToProcess if not toNodes: self.raiseImpossibleProcess("COMPUTATION") nodes, edges = graph.dfsToProcess(startNodes=toNodes) if not nodes: logging.warning('Nothing to compute') return self.checkCompatibilityNodes(graph, nodes, "COMPUTATION") # name of the context is important for QML self.checkDuplicates(nodes, "COMPUTATION") # name of the context is important for QML nodes = [node for node in nodes if not self.contains(node)] # be sure to avoid non-real conflicts nodes = list(set(nodes)) nodes = sorted(nodes, key=lambda x: x.depth) chunksInConflict = self.getAlreadySubmittedChunks(nodes) if chunksInConflict: chunksStatus = {chunk.status.status.name for chunk in chunksInConflict} chunksName = [node.name for node in chunksInConflict] # Warning: Syntax and terms are parsed on QML side to recognize the error # Syntax : [Context] ErrorType: ErrorMessage msg = f'[COMPUTATION] Already Submitted:\nWARNING - Some nodes are already submitted with status: ' \ f'{", ".join(chunksStatus)}\nNodes: {", ".join(chunksName)}' if forceStatus: logging.warning(msg) else: raise RuntimeError(msg) for node in nodes: node.destroyed.connect(lambda obj=None, name=node.name: self.onNodeDestroyed(obj, name)) node.initStatusOnCompute(forceCompute) self._nodes.update(nodes) self._nodesToProcess.extend(nodes) if self._thread._state == State.IDLE: self._thread.start() elif self._thread._state in (State.DEAD, State.ERROR): self._thread = TaskThread(self) self._thread.start() # At the end because it raises a WarningError but should not stop processing if not allReady: self.raiseDependenciesMessage("COMPUTATION") def onNodeDestroyed(self, obj, name): """ Remove node from the taskmanager when it is destroyed in the graph :param obj: :param name: :return: """ if name in self._nodes.keys(): self._nodes.pop(name) def contains(self, node): return node in self._nodes.values() def containsNodeName(self, name): """ Check if a node with the argument name belongs to the display list. """ if name in self._nodes.keys(): return True return False def removeNode(self, node, displayList=True, processList=False, externList=False): """ Remove node from the Task Manager. Args: node (Node): node to remove. displayList (bool): remove from the display list. processList (bool): remove from the nodesToProcess list. externList (bool): remove from the nodesExtern list. """ if displayList and self._nodes.contains(node): self._nodes.pop(node.name) if processList and node in self._nodesToProcess: self._nodesToProcess.remove(node) if externList and node in self._nodesExtern: self._nodesExtern.remove(node) def clear(self): """ Remove all the nodes from the taskmanager :return: """ self._nodes.clear() self._nodesExtern = [] self._nodesToProcess = [] def updateNodes(self): """ Update task manager nodes lists by checking the nodes status. """ self._nodesExtern = [node for node in self._nodesExtern if node.isExtern() and node.isAlreadySubmitted()] newNodes = [node for node in self._nodes if node.isAlreadySubmitted()] if len(newNodes) != len(self._nodes): self._nodes.clear() self._nodes.update(newNodes) def update(self, graph): """ Add all the nodes that are being rendered in a renderfarm to the taskmanager when new graph is loaded :param graph: :return: """ for node in graph._nodes: if node.isAlreadySubmitted() and node._chunks.size() > 0 and node.isExtern(): self._nodes.add(node) self._nodesExtern.append(node) def checkCompatibilityNodes(self, graph, nodes, context): compatNodes = [] for node in nodes: if node in graph._compatibilityNodes.values(): compatNodes.append(node.nameToLabel(node.name)) if compatNodes: # Warning: Syntax and terms are parsed on QML side to recognize the error # Syntax : [Context] ErrorType: ErrorMessage raise RuntimeError(f"[{context}] Compatibility Issue:\n" f"Cannot compute because of these incompatible nodes:\n" f"{sorted(compatNodes)}") def checkDuplicates(self, nodesToProcess, context): for node in nodesToProcess: for duplicate in node.duplicates: if duplicate in nodesToProcess: # Warning: Syntax and terms are parsed on QML side to recognize the error # Syntax : [Context] ErrorType: ErrorMessage raise RuntimeError(f"[{context}] Duplicates Issue:\n" f"Cannot compute because there are some duplicate nodes to process:\n\n" f"First match: '{node.nameToLabel(node.name)}' and '{node.nameToLabel(duplicate.name)}'\n\n" f"There can be other duplicate nodes in the list. " f"Please, check the graph and try again.") def checkNodesDependencies(self, graph, toNodes, context): """ Check dependencies of nodes to process. Update toNodes with computable/submittable nodes only. Returns: bool: True if all the nodes can be processed. False otherwise. """ ready = [] computed = [] inputNodes = [] for node in toNodes: if node.isInputNode: inputNodes.append(node) elif context == "COMPUTATION": if graph.canComputeTopologically(node) and graph.canSubmitOrCompute(node) % 2 == 1: ready.append(node) elif node.isComputed: computed.append(node) elif context == "SUBMITTING": if graph.canComputeTopologically(node) and graph.canSubmitOrCompute(node) > 1: ready.append(node) elif node.isComputed: computed.append(node) else: raise ValueError("Argument 'context' must be: 'COMPUTATION' or 'SUBMITTING'") if len(ready) + len(computed) + len(inputNodes) != len(toNodes): toNodes.clear() toNodes.extend(ready) return False return True def raiseDependenciesMessage(self, context): # Warning: Syntax and terms are parsed on QML side to recognize the error # Syntax : [Context] ErrorType: ErrorMessage raise RuntimeWarning(f"[{context}] Unresolved dependencies:\n" f"Some nodes cannot be computed in LOCAL/submitted in EXTERN because of " f"unresolved dependencies.\n\n" f"Nodes which are ready will be processed.") def raiseImpossibleProcess(self, context): # Warning: Syntax and terms are parsed on QML side to recognize the error # Syntax : [Context] ErrorType: ErrorMessage raise RuntimeError(f"[{context}] Impossible Process:\n" f"There is no node able to be processed.") def submit(self, graph, submitter=None, toNodes=None, submitLabel="{projectName}"): """ Nodes are send to the renderfarm :param graph: :param submitter: :param toNodes: :return: """ # Ensure submitter is properly set sub = None if submitter: sub = meshroom.core.submitters.get(submitter, None) elif len(meshroom.core.submitters) >= 1: # if only one submitter available use it allSubmitters = meshroom.core.submitters.values() sub = next(iter(allSubmitters)) # retrieve the first element if sub is None: # Warning: Syntax and terms are parsed on QML side to recognize the error # Syntax : [Context] ErrorType: ErrorMessage raise RuntimeError(f"[SUBMITTING] Unknown Submitter:\n" f"Unknown Submitter called '{submitter}'. " f"Available submitters are: '{str(meshroom.core.submitters.keys())}'.") # TODO : If possible with the submitter (ATTACH_JOB) # Update task manager's lists self.updateNodes() graph.update() # Check dependencies of toNodes if not toNodes: toNodes = graph.getLeafNodes(dependenciesOnly=True) toNodes = list(toNodes) toNodes = [node for node in toNodes if not node.isBackdropNode] allReady = self.checkNodesDependencies(graph, toNodes, "SUBMITTING") # At this point, toNodes is a list # If it is empty, we raise an error to avoid passing through dfsToProcess if not toNodes: self.raiseImpossibleProcess("SUBMITTING") nodesToProcess, edgesToProcess = graph.dfsToProcess(startNodes=toNodes) if not nodesToProcess: logging.warning('Nothing to compute') return self.checkCompatibilityNodes(graph, nodesToProcess, "SUBMITTING") # name of the context is important for QML self.checkDuplicates(nodesToProcess, "SUBMITTING") # name of the context is important for QML # Update nodes status for node in nodesToProcess: node.destroyed.connect(lambda obj=None, name=node.name: self.onNodeDestroyed(obj, name)) node.initStatusOnSubmit() jobManager.resetNodeJob(node) graph.updateMonitoredFiles() flowEdges = graph.flowEdges(startNodes=toNodes) edgesToProcess = set(edgesToProcess).intersection(flowEdges) logging.info(f"Nodes to process: {nodesToProcess}") logging.info(f"Edges to process: {edgesToProcess}") try: res = sub.submit(nodesToProcess, edgesToProcess, graph.filepath, submitLabel=submitLabel) if res: if isinstance(res, BaseSubmittedJob): jobManager.addJob(res, nodesToProcess) else: for node in nodesToProcess: # TODO : Notify the node that there was an issue on submit pass self._nodes.update(nodesToProcess) self._nodesExtern.extend(nodesToProcess) # At the end because it raises a WarningError but should not stop processing if not allReady: self.raiseDependenciesMessage("SUBMITTING") except Exception as exc: logging.error(f"Error on submit : {exc}\n{traceback.format_exc()}") def submitFromFile(self, graphFile, submitter, toNode=None, submitLabel="{projectName}"): """ Submit the given graph via the given submitter. """ graph = meshroom.core.graph.loadGraph(graphFile) self.submit(graph, submitter, toNode, submitLabel=submitLabel) def getAlreadySubmittedChunks(self, nodes): """ Check if nodes have already been submitted in another Meshroom instance. :param nodes: :return: """ out = [] for node in nodes: for chunk in node.chunks: # Already submitted/running chunks in another task manager if chunk.isAlreadySubmitted() and not self.containsNodeName(chunk.statusNodeName): out.append(chunk) return out nodes = Property(BaseObject, lambda self: self._nodes, constant=True) chunksCreated = Signal(BaseObject) restartRequested = Signal() ================================================ FILE: meshroom/core/test.py ================================================ #!/usr/bin/env python import json from meshroom.core import pipelineTemplates, Version from meshroom.core.node import CompatibilityIssue, CompatibilityNode from meshroom.core.graphIO import GraphIO import meshroom def checkTemplateVersions(path: str, nodesAlreadyLoaded: bool = False) -> bool: """ Check whether there is a compatibility issue with the nodes saved in the template provided with "path". """ if not nodesAlreadyLoaded: meshroom.core.initNodes() with open(path) as jsonFile: fileData = json.load(jsonFile) try: graphData = fileData.get(GraphIO.Keys.Graph, fileData) if not isinstance(graphData, dict): print(f"File '{path}' does not contain a valid graph.") return False header = fileData.get(GraphIO.Keys.Header, {}) if not header.get("template", False): print(f"File '{path}' is not a valid template.") return False nodesVersions = header.get(GraphIO.Keys.NodesVersions, {}) for _, nodeData in graphData.items(): nodeType = nodeData["nodeType"] if not meshroom.core.pluginManager.isRegistered(nodeType): print(f"'{nodeType}' in '{path}' is an unknown type.") return False nodeDesc = meshroom.core.pluginManager.getRegisteredNodePlugin(nodeType).nodeDescriptor currentNodeVersion = meshroom.core.nodeVersion(nodeDesc) inputs = nodeData.get("inputs", {}) internalInputs = nodeData.get("internalInputs", {}) version = nodesVersions.get(nodeType, None) compatibilityIssue = None if version and currentNodeVersion and Version(version).major != Version(currentNodeVersion).major: compatibilityIssue = CompatibilityIssue.VersionConflict else: for attrName, value in inputs.items(): if not CompatibilityNode.attributeDescFromName(nodeDesc.inputs, attrName, value): compatibilityIssue = CompatibilityIssue.DescriptionConflict break for attrName, value in internalInputs.items(): if not CompatibilityNode.attributeDescFromName(nodeDesc.internalInputs, attrName, value): compatibilityIssue = CompatibilityIssue.DescriptionConflict break if compatibilityIssue is not None: print(f"{compatibilityIssue} in '{path}' for node '{nodeType}'.") return False return True finally: if not nodesAlreadyLoaded: nodePlugins = meshroom.core.pluginManager.getRegisteredNodePlugins() for node in nodePlugins: meshroom.core.pluginManager.unregisterNode(node) def checkAllTemplatesVersions() -> bool: meshroom.core.initNodes() meshroom.core.initPipelines() validVersions = [] for _, path in pipelineTemplates.items(): validVersions.append(checkTemplateVersions(path, nodesAlreadyLoaded=True)) return all(validVersions) ================================================ FILE: meshroom/core/utils.py ================================================ COLORSPACES = ["AUTO", "sRGB", "rec709", "Linear", "ACES2065-1", "ACEScg", "Linear ARRI Wide Gamut 3", "ARRI LogC3 (EI800)", "Linear ARRI Wide Gamut 4", "ARRI LogC4", "Linear BMD WideGamut Gen5", "BMDFilm WideGamut Gen5", "CanonLog2 CinemaGamut D55", "CanonLog3 CinemaGamut D55", "Linear CinemaGamut D55", "Linear V-Gamut", "V-Log V-Gamut", "Linear REDWideGamutRGB", "Log3G10 REDWideGamutRGB", "Linear Venice S-Gamut3.Cine", "S-Log3 Venice S-Gamut3.Cine", "no_conversion"] DESCRIBER_TYPES = ["sift", "sift_float", "sift_upright", "dspsift", "akaze", "akaze_liop", "akaze_mldb", "cctag3", "cctag4", "sift_ocv", "akaze_ocv", "tag16h5", "survey", "unknown"] EXR_STORAGE_DATA_TYPE = ["float", "half", "halfFinite", "auto"] RAW_COLOR_INTERPRETATION = ["None", "LibRawNoWhiteBalancing", "LibRawWhiteBalancing", "DCPLinearProcessing", "DCPMetadata", "Auto"] VERBOSE_LEVEL = ["fatal", "error", "warning", "info", "debug", "trace"] ================================================ FILE: meshroom/env.py ================================================ """ Meshroom environment variable management. """ __all__ = [ "EnvVar", "EnvVarHelpAction", ] import argparse import os from dataclasses import dataclass from enum import Enum import sys import tempfile from typing import Any, Type meshroomFolder = os.path.dirname(__file__) @dataclass class VarDefinition: """Environment variable definition.""" # The type to cast the value to. valueType: Type # Default value if the variable is not set in the environment. default: str # Description of the purpose of the variable. description: str = "" def __str__(self) -> str: return f"{self.description} ({self.valueType.__name__}, default: '{self.default}')" class EnvVar(Enum): """Meshroom environment variables catalog.""" # UI - Debug MESHROOM_QML_DEBUG = VarDefinition(bool, "False", "Enable QML debugging") MESHROOM_QML_DEBUG_PARAMS = VarDefinition( str, "port:3768", "QML debugging params as expected by -qmljsdebugger" ) # Core MESHROOM_PLUGINS_PATH = VarDefinition(str, "", "Paths to plugins folders containing nodes, submitters and pipeline templates.") MESHROOM_NODES_PATH = VarDefinition(str, "", "Paths to set of nodes folders.") MESHROOM_SUBMITTERS_PATH = VarDefinition(str, "", "Paths to set of submitters folders.") MESHROOM_PIPELINE_TEMPLATES_PATH = VarDefinition(str, "", "Paths to et of pipeline templates folders.") MESHROOM_REZ_PLUGINS = VarDefinition(str, "", "List of Rez plugins, defined by the package name associated with the plugin's root path. " "For example, 'packageA=/path/to/packageA/version/root'.") MESHROOM_TEMP_PATH = VarDefinition(str, tempfile.gettempdir(), "Path to the temporary folder.") @staticmethod def get(envVar: "EnvVar") -> Any: """Get the value of `envVar`, cast to the variable type.""" value = os.environ.get(envVar.name, envVar.value.default) return EnvVar._cast(value, envVar.value.valueType) @staticmethod def getList(envVar: "EnvVar") -> list[Any]: """Get the value of `envVar` as a list of non-empty strings.""" paths = EnvVar.get(envVar).split(os.pathsep) # filter empty values return [p for p in paths if p] @staticmethod def _cast(value: str, valueType: Type) -> Any: if valueType is str: return value elif valueType is bool: return value.lower() in {"true", "1", "on", "yes", "y"} return valueType(value) @classmethod def help(cls) -> str: """Return a formatted string with the details of each environment variables.""" return "\n".join([f"{var.name}: {var.value}" for var in cls]) class EnvVarHelpAction(argparse.Action): """Argparse action for printing Meshroom environment variables help and exit.""" DEFAULT_HELP = "Print Meshroom environment variables help and exit." def __call__(self, parser, namespace, value, option_string=None): print("Meshroom environment variables:") print(EnvVar.help()) sys.exit(0) ================================================ FILE: meshroom/multiview.py ================================================ import os # Supported image extensions imageExtensions = [ # bmp: '.bmp', # cineon: '.cin', # dds '.dds', # dpx: '.dpx', # gif: '.gif', # hdr: '.hdr', '.rgbe', # heif '.heic', '.heif', '.avif', # ico: '.ico', # iff: '.iff', '.z', # jpeg: '.jpg', '.jpe', '.jpeg', '.jif', '.jfif', '.jfi', # jpeg2000: '.jp2', '.j2k', '.j2c', # openexr: '.exr', '.sxr', '.mxr', # png: '.png', # pnm: '.ppm', '.pgm', '.pbm', '.pnm', '.pfm', # psd: '.psd', '.pdd', '.psb', # ptex: '.ptex', '.ptx', # raw: '.bay', '.bmq', '.cr2', '.cr3', '.crw', '.cs1', '.dc2', '.dcr', '.dng', '.erf', '.fff', '.k25', '.kdc', '.mdc', '.mos', '.mrw', '.nef', '.orf', '.pef', '.pxn', '.raf', '.raw', '.rdc', '.sr2', '.srf', '.x3f', '.arw', '.3fr', '.cine', '.ia', '.kc2', '.mef', '.nrw', '.qtk', '.rw2', '.sti', '.rwl', '.srw', '.drf', '.dsc', '.cap', '.iiq', '.rwz', # rla: '.rla', # sgi: '.sgi', '.rgb', '.rgba', '.bw', '.int', '.inta', # socket: '.socket', # softimage: '.pic', # tiff: '.tiff', '.tif', '.tx', '.env', '.sm', '.vsm', # targa: '.tga', '.tpic', # webp: 'webp', # zfile: '.zfile', # osl: '.osl', '.oso', '.oslgroup', '.oslbody', ] videoExtensions = [ '.avi', '.mov', '.qt', '.mkv', '.webm', '.mp4', '.mpg', '.mpeg', '.m2v', '.m4v', '.wmv', '.ogv', '.ogg', '.mxf', ] panoramaInfoExtensions = ['.xml'] meshroomSceneExtensions = ['.mg'] 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 class FilesByType: def __init__(self): self.images = [] self.videos = [] self.panoramaInfo = [] self.meshroomScenes = [] self.other = [] def __bool__(self): return self.images or self.videos or self.panoramaInfo or self.meshroomScenes 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.other.extend(other.other) def addFile(self, file): if hasExtension(file, imageExtensions): self.images.append(file) elif hasExtension(file, videoExtensions): self.videos.append(file) elif hasExtension(file, panoramaInfoExtensions): self.panoramaInfo.append(file) elif hasExtension(file, meshroomSceneExtensions): self.meshroomScenes.append(file) else: self.other.append(file) def addFiles(self, files): for file in files: self.addFile(file) def findFilesByTypeInFolder(folder, recursive=False): """ Return all files that are images in 'folder' based on their extensions. Args: folder (str): folder to look into or list of folder/files Returns: list: the list of image files with a supported extension. """ inputFolders = [] if isinstance(folder, (list, tuple)): inputFolders = folder else: inputFolders.append(folder) output = FilesByType() for currentFolder in inputFolders: currentFolder = os.path.abspath(currentFolder) if os.path.isfile(currentFolder): output.addFile(currentFolder) continue elif os.path.isdir(currentFolder): if recursive: # Get through all of the depth levels for root, directories, files in os.walk(currentFolder): for filename in files: output.addFile(os.path.join(root, filename)) else: # Only get the first level of depth, so top-level folders' # files will be added, if they exist. # This may prevent from importing nothing at all when files # are nested a level down try: root, directories, files = next(os.walk(currentFolder)) output.addFiles([os.path.join(root, file) for file in files]) for directory in directories: for file in os.listdir(os.path.join(root, directory)): filepath = os.path.join(root, directory, file) if os.path.isfile(filepath): output.addFile(filepath) except (StopIteration, OSError): # Directory empty or inaccessible, skip processing pass else: # If not a directory or a file, it may be an expression import glob paths = glob.glob(currentFolder) filesByType = findFilesByTypeInFolder(paths, recursive=recursive) output.extend(filesByType) return output ================================================ FILE: meshroom/nodes/__init__.py ================================================ ================================================ FILE: meshroom/nodes/general/Backdrop.py ================================================ __version__ = "1.0" from meshroom.core import desc class Backdrop(desc.BackdropNode): """ Backdrop node. Any node can be placed inside a backdrop to group them visually. """ category = "Utils" ================================================ FILE: meshroom/nodes/general/CopyFiles.py ================================================ __version__ = "1.3" from meshroom.core import desc from meshroom.core.utils import VERBOSE_LEVEL import shutil import glob import os class CopyFiles(desc.Node): size = desc.DynamicNodeSize("inputFiles") category = "Export" documentation = """ This node allows to copy files into a specific folder. """ inputs = [ desc.ListAttribute( elementDesc=desc.File( name="input", label="Input", description="File or folder to copy.", value="", ), name="inputFiles", label="Input Files", description="Input files or folders' content to copy.", exposed=True, ), desc.File( name="output", label="Output Folder", description="Folder to copy to.", value="", ), desc.ChoiceParam( name="verboseLevel", label="Verbose Level", description="Verbosity level (fatal, error, warning, info, debug, trace).", values=VERBOSE_LEVEL, value="info", ), ] def resolvedPaths(self, inputFiles, outDir): paths = {} for inputFile in inputFiles: for f in glob.glob(inputFile.value): if os.path.isdir(f): # Do not concatenate the input folder's name with the output's paths[f] = outDir else: paths[f] = os.path.join(outDir, os.path.basename(f)) return paths def processChunk(self, chunk): try: chunk.logManager.start(chunk.node.verboseLevel.value) if not chunk.node.inputFiles: chunk.logger.warning("No file to copy.") return if not chunk.node.output.value: return outFiles = self.resolvedPaths(chunk.node.inputFiles.value, chunk.node.output.value) if not outFiles: error = "CopyFiles: input files listed, but nothing to copy." chunk.logger.error(error) chunk.logger.info(f"Listed input files: {[i.value for i in chunk.node.inputFiles.value]}.") raise RuntimeError(error) if not os.path.exists(chunk.node.output.value): os.makedirs(chunk.node.output.value) for iFile, oFile in outFiles.items(): # If the input is a directory, copy the directory's content if os.path.isdir(iFile): chunk.logger.info(f"CopyFiles directory {iFile} into {oFile}.") shutil.copytree(iFile, oFile) else: chunk.logger.info(f"CopyFiles file {iFile} into {oFile}.") shutil.copyfile(iFile, oFile) chunk.logger.info("CopyFiles end.") finally: chunk.logManager.end() ================================================ FILE: meshroom/nodes/general/InputFile.py ================================================ __version__ = "1.0" import logging import os from meshroom.core import desc class InputFile(desc.InputNode, desc.InitNode): """ This node is an input node that receives a File. """ category = "Other" inputs = [ desc.File( name="inputFile", label="Input File", description="A file or folder to use as the input.", value="", ) ] def initialize(self, node, inputs, recursiveInputs): self.resetAttributes(node, ["inputFile"]) if len(inputs) >= 1: if os.path.isfile(inputs[0]) or os.path.isdir(inputs[0]): self.setAttributes(node, {"inputFile": inputs[0]}) if len(inputs) > 1: logging.warning(f"Several inputs were provided ({inputs}).") logging.warning(f"Only the first one ({inputs[0]}) will be used.") else: raise RuntimeError(f"{inputs[0]} is not a valid file or directory.") elif len(recursiveInputs) >= 1: if os.path.isfile(recursiveInputs[0]) or os.path.isdir(recursiveInputs[0]): self.setAttributes(node, {"inputFile": recursiveInputs[0]}) if len(recursiveInputs) > 1: logging.warning(f"Several recursive inputs were provided ({recursiveInputs}).") logging.warning(f"Only the first valid one ({recursiveInputs[0]}) will be used.") else: raise RuntimeError(f"{recursiveInputs[0]} is not a valid file or directory.") else: raise RuntimeError("No file or directory has been set for 'inputFile'.") ================================================ FILE: meshroom/nodes/general/__init__.py ================================================ ================================================ FILE: meshroom/submitters/__init__.py ================================================ ================================================ FILE: meshroom/submitters/localFarmSubmitter.py ================================================ #!/usr/bin/env python import os import re import shutil import logging from pathlib import Path from typing import List, Dict from meshroom.core.submitter import BaseSubmitter, SubmitterOptions, BaseSubmittedJob, SubmitterOptionsEnum from meshroom.core.node import Status from collections import namedtuple, defaultdict from localfarm.localFarm import Task, Job, LocalFarmEngine logger = logging.getLogger("LocalFarmSubmitter") logger.setLevel(logging.INFO) DEFAULT_FARM_PATH = os.getenv("MR_LOCAL_FARM_PATH", os.path.join(os.path.expanduser("~"), ".local_farm")) REZ_DELIMITER_PATTERN = re.compile(r"(-|==|>=|>|<=|<)") MESHROOM_ROOT = Path(__file__).resolve().parent.parent.parent Chunk = namedtuple("chunk", ["iteration", "start", "end"]) CreatedTask = namedtuple("task", ["task", "chunkParams"]) def wrapMeshroomBin(_bin): if shutil.which(_bin): # The alias exists so use it directly return _bin binFolder = str(MESHROOM_ROOT / "bin") return os.path.join(binFolder, _bin) def getResolvedVersionsDict(): """ Get a dict {packageName: version} corresponding to the current context. """ resolvedPackages = os.environ.get('REZ_RESOLVE', '').split() resolvedVersions = {} for r in resolvedPackages: if r.startswith('~'): # remove implicit packages continue v = r.split('-') if len(v) == 2: resolvedVersions[v[0]] = v[1] elif len(v) > 2: # Handle case with multiple hyphen-minus resolvedVersions[v[0]] = "-".join(v[1:]) return resolvedVersions def getRequestPackages(packagesDelimiter="=="): """ Get list of packages required for the job. Depends on env var and current rez context. By default we use the "==" delimiter to make sure we have the same version in the job that the one we have in the env where Meshroom is launched. """ reqPackages = set() if 'REZ_REQUEST' in os.environ: # Get the names of the packages that have been requested requestedPackages = os.environ.get('REZ_USED_REQUEST', '').split() usedPackages = set() # Use set to remove duplicates for p in requestedPackages: if p.startswith('~') or p.startswith("!"): continue v = REZ_DELIMITER_PATTERN.split(p) usedPackages.add(v[0]) # Add requested packages to the reqPackages set resolvedVersions = getResolvedVersionsDict() for p in usedPackages: reqPackages.add(packagesDelimiter.join([p, resolvedVersions[p]])) logging.debug(f"LocalFarmSubmitter: REZ Packages: {str(reqPackages)}") elif 'REZ_MESHROOM_VERSION' in os.environ: reqPackages.add(f"meshroom{packagesDelimiter}{os.environ.get('REZ_MESHROOM_VERSION', '')}") return list(reqPackages) def rezWrapCommand(cmd, useCurrentContext=False, useRequestedContext=True, otherRezPkg: list[str] = None): """ Wrap command to be runned using rez. :param cmd: command to run :type cmd: bool :param useCurrentContext: use current rez context to retrieve a list of rez packages :type useCurrentContext: bool :param useRequestedContext: use rez packages that have been requested (not the full context) # TODO : remove it :type useRequestedContext: bool :param otherRezPkg: Additionnal rez packages :type otherRezPkg: list[str] """ packages = set() if useCurrentContext: # In this case we want to use the full context packages.update([p for p in os.environ.get('REZ_RESOLVE', '').split(" ") if p]) elif useRequestedContext: # In this case we want to use only packages in the rez request packages.update(getRequestPackages()) # Add additional packages if otherRezPkg: packages.update(otherRezPkg) packagesStr = " ".join([p for p in packages if p]) if packagesStr: rezBin = "rez" if "REZ_BIN" in os.environ and os.environ["REZ_BIN"]: rezBin = os.environ["REZ_BIN"] elif "REZ_PACKAGES_ROOT" in os.environ and os.environ["REZ_PACKAGES_ROOT"]: rezBin = os.path.join(os.environ["REZ_PACKAGES_ROOT"], "bin/rez") elif shutil.which("rez"): rezBin = shutil.which("rez") return f"{rezBin} env {packagesStr} -- {cmd}" return cmd class LocalFarmJob(BaseSubmittedJob): """ Interface to manipulate the job via Meshroom. """ def __init__(self, jid, submitter, farmPath=None): super().__init__(jid, submitter) self.jid = jid self.submitter: LocalFarmSubmitter = submitter self.__localJob = None self.__localJobTasks = None self.farmPath = farmPath or DEFAULT_FARM_PATH self._engine = LocalFarmEngine(self.farmPath) def __getJobInfo(self): """ Find job. """ self.__localJob = self._engine.get_job_info(self.jid) self.__localJobTasks = {t.get("tid"): t for t in self.__localJob["tasks"]} @property def localfarmJob(self): if not self.__localJob: self.__getJobInfo() return self.__localJob @property def localfarmTasks(self): if not self.__localJobTasks: self.__getJobInfo() return self.__localJobTasks def __getChunkTasks(self, nodeUid, iteration): tasks = [] for _, task in self.localfarmTasks.items(): taskNodeUid = task["metadata"].get("nodeUid", None) taskIt = task["metadata"].get("iteration", -1) if taskNodeUid == nodeUid and taskIt == iteration: tasks.append(task) return tasks # Task actions def stopChunkTask(self, node, iteration): """ This will kill one task. """ tasks = self.__getChunkTasks(node._uid, iteration) for task in tasks: self._engine.stop_task(self.jid, task["tid"]) def skipChunkTask(self, node, iteration): """ This will skip one task. """ tasks = self.__getChunkTasks(node._uid, iteration) for task in tasks: self._engine.skip_task(self.jid, task["tid"]) def restartChunkTask(self, node, iteration): """ This will restart one task. """ tasks = self.__getChunkTasks(node._uid, iteration) for task in tasks: self._engine.restart_task(self.jid, task["tid"]) # Job actions def getJobErrors(self): """ Check for error in the job. """ return self._engine.get_job_errors(self.jid) def pauseJob(self): """ This will pause the job: new tasks will not be processed. """ self._engine.pause_job(self.jid) def resumeJob(self): """ This will unpause the job. """ self._engine.unpause_job(self.jid) def interruptJob(self): """ This will interrupt the job (and kill running tasks). """ self._engine.interrupt_job(self.jid) def restartJob(self): """ Restart the whole job. """ self._engine.restart_job(self.jid) def restartErrorTasks(self): """ Restart all error tasks on the job. """ self._engine.restart_error_tasks(self.jid) class LocalFarmSubmitter(BaseSubmitter): """ Meshroom submitter to localfarm. """ _name = "LocalFarm" _options = SubmitterOptions(SubmitterOptionsEnum.ALL) dryRun = False environment = {} def __init__(self, parent=None): super().__init__(parent=parent) self.farmPath = DEFAULT_FARM_PATH self.reqPackages = getRequestPackages() self.jobEnv = {} def setFarmPath(self, path: str): self.farmPath = path def setJobEnv(self, env: dict): self.jobEnv = env def retrieveJob(self, jid) -> LocalFarmJob: job = LocalFarmJob(jid, self, farmPath=self.farmPath) return job @staticmethod def getChunks(chunkParams) -> list[Chunk]: """ Get list of chunks. """ it = None ignoreIterations = chunkParams.get("ignoreIterations", []) if chunkParams: start, end = chunkParams.get("start", -1), chunkParams.get("end", -2) size = chunkParams.get("packetSize", 1) frameRange = list(range(start, end+1, 1)) if frameRange: slices = [frameRange[i:i + size] for i in range(0, len(frameRange), size)] it = [Chunk(i, item[0], item[-1]) for i, item in enumerate(slices) if i not in ignoreIterations] return it @staticmethod def getExpandWrappedCmd(cmdArgs, rezPackages): # Wrap with create_chunks cmdBin = wrapMeshroomBin("meshroom_createChunks") cmd = f"{cmdBin} --submitter LocalFarm {cmdArgs}" # Wrap with rez cmd = rezWrapCommand(cmd, otherRezPkg=rezPackages) return cmd def __createChunkTasks(self, job: Job, parentTask: Task, children: List[Task], chunkParams: dict) -> Task: cmdArgs = chunkParams.get("chunkCmdArgs") chunks = self.getChunks(chunkParams) for c in chunks: name = f"{parentTask.name}_{c.start}_{c.end}" meta = parentTask.metadata.copy() meta["iteration"] = c.iteration cmdBin = wrapMeshroomBin("meshroom_compute") cmd = f"{cmdBin} {cmdArgs} --iteration {c.iteration}" cmd = rezWrapCommand(cmd, otherRezPkg=self.reqPackages) chunkTask = Task(name=name, command=cmd, metadata=meta, env=self.jobEnv) job.addTask(chunkTask) for child in children: job.addTaskDependency(child, chunkTask) job.addTaskDependency(chunkTask, parentTask) def createTask(self, meshroomFile: str, node) -> CreatedTask: cmdArgs = f"--node {node.name} \"{meshroomFile}\" --extern" metadata = {"nodeUid": node._uid} if not node._chunksCreated: cmd = self.getExpandWrappedCmd(cmdArgs, self.reqPackages) task = Task(name=node.name, command=cmd, metadata=metadata, env=self.jobEnv) task = CreatedTask(task, None) elif node.isParallelized: _, _, nbBlocks = node.nodeDesc.parallelization.getSizes(node) iterationsToIgnore = [] for c in node._chunks: if c._status.status == Status.SUCCESS: iterationsToIgnore.append(c.range.iteration) chunkParams = { "start": 0, "end": nbBlocks - 1, "step": 1, "ignoreIterations": iterationsToIgnore, "chunkCmdArgs": cmdArgs } task = Task(name=node.name, command="", metadata=metadata, env=self.jobEnv) task = CreatedTask(task, chunkParams) else: cmdBin = wrapMeshroomBin("meshroom_compute") cmd = f"{cmdBin} {cmdArgs} --iteration 0" cmd = rezWrapCommand(cmd, otherRezPkg=self.reqPackages) task = Task(name=node.name, command=cmd, metadata=metadata, env=self.jobEnv) task = CreatedTask(task, None) print("Created task: ", task) return task def buildDependencies(self, job: Job, nodeUidToTask: Dict[str, CreatedTask], edges): """ Gather and create dependencies. First we get all parents and all children for each task. Then for each task: - we add the dependency to their parent and children - if the task is a chunked task (which means multi iteration tasks) the we create the chunk tasks and add dependencies from chunk tasks to children tasks # TODO: there is a lot of confusion between nodes and tasks here """ # Gather dependencies tasksParentsUids = defaultdict(set) tasksChildrenUids = defaultdict(set) for u, v in edges: # tasksParentsUids[v._uid].add(u._uid) # tasksChildrenUids[u._uid].add(v._uid) tasksParentsUids[u._uid].add(v._uid) tasksChildrenUids[v._uid].add(u._uid) # Create dependencies for taskUid, createdTask in nodeUidToTask.items(): parentsTasks = [nodeUidToTask[tuid].task for tuid in tasksParentsUids.get(taskUid, set())] childrenTasks = [nodeUidToTask[tuid].task for tuid in tasksChildrenUids.get(taskUid, set())] # Create regular dependencies for parentTask in parentsTasks: job.addTaskDependency(createdTask.task, parentTask) for childTask in childrenTasks: job.addTaskDependency(childTask, createdTask.task) # Create chunk tasks if necessary if createdTask.chunkParams: self.__createChunkTasks(job, createdTask.task, childrenTasks, createdTask.chunkParams) def createJob(self, nodes, edges, filepath, submitLabel="{projectName}") -> LocalFarmJob: projectName = os.path.splitext(os.path.basename(filepath))[0] name = submitLabel.format(projectName=projectName) # Create job job = Job(name) # Create tasks nodeUidToTask: Dict[str, CreatedTask] = {} for node in nodes: if node._uid in nodeUidToTask: continue # HACK: Should not be necessary createdTask: CreatedTask = self.createTask(filepath, node) job.addTask(createdTask.task) nodeUidToTask[node._uid] = createdTask # Build dependencies self.buildDependencies(job, nodeUidToTask, edges) # Submit job engine = LocalFarmEngine(self.farmPath) res = job.submit(engine) print(f"Submitted job : {res}") if self.dryRun: return True if len(res) == 0: return False submittedJob = LocalFarmJob(res.get("jid"), LocalFarmSubmitter, farmPath=self.farmPath) return submittedJob def createChunkTask(self, node, graphFile, **kwargs): """ Dynamically create chunk tasks for the given node (executed by meshroom_createChunks). """ # Retrieve current job/task info currentJid, currentTid = int(os.getenv("LOCALFARM_CURRENT_JID")), int(os.getenv("LOCALFARM_CURRENT_TID")) # Make sure we inherit current MESHROOM_PLUGINS_PATH for submission # TODO: later we can immplement a proper env inheriting system like what we have in tractor taskEnv = { "MESHROOM_PLUGINS_PATH": os.environ.get("MESHROOM_PLUGINS_PATH", "") } if self.jobEnv: taskEnv.update(self.jobEnv) # Get engine engine = LocalFarmEngine(self.farmPath) # Get chunk info cmdArgs = f"--node {node.name} \"{graphFile}\" --extern" _, _, nbBlocks = node.nodeDesc.parallelization.getSizes(node) if nbBlocks <= 0: return chunkRangeParams = {'start': 0, 'end': nbBlocks - 1, 'step': 1} # Create subtasks for chunk in self.getChunks(chunkRangeParams): name = f"{node.name}_{chunk.start}_{chunk.end}" metadata = {"nodeUid": node._uid, "iteration": chunk.iteration} cmdBin = wrapMeshroomBin("meshroom_compute") cmd = f"{cmdBin} {cmdArgs} --iteration {chunk.iteration}" cmd = rezWrapCommand(cmd, otherRezPkg=self.reqPackages) print("Additional chunk task command: ", cmd) task = Task(name=name, command=cmd, metadata=metadata, env=taskEnv) engine.create_additional_task(currentJid, currentTid, task) ================================================ FILE: meshroom/ui/__init__.py ================================================ ================================================ FILE: meshroom/ui/__main__.py ================================================ import signal import sys import meshroom from meshroom.common import Backend meshroom.setupEnvironment(backend=Backend.PYSIDE) signal.signal(signal.SIGINT, signal.SIG_DFL) import meshroom.ui import meshroom.ui.app meshroom.ui.uiInstance = meshroom.ui.app.MeshroomApp(sys.argv) meshroom.ui.uiInstance.aboutToQuit.connect(meshroom.ui.uiInstance.terminateManual) meshroom.ui.uiInstance.exec() ================================================ FILE: meshroom/ui/app.py ================================================ import logging import os import re import argparse import json from enum import Enum from PySide6 import __version__ as PySideVersion from PySide6 import QtCore from PySide6.QtCore import QUrl, QJsonValue, qInstallMessageHandler, QtMsgType, QSettings from PySide6.QtGui import QIcon from PySide6.QtQml import QQmlDebuggingEnabler from PySide6.QtQuickControls2 import QQuickStyle from PySide6.QtWidgets import QApplication import meshroom from meshroom.core import pluginManager from meshroom.core.submitter import BaseSubmitter from meshroom.core.taskManager import TaskManager from meshroom.common import Property, Variant, Signal, Slot from meshroom.env import EnvVar, EnvVarHelpAction from meshroom.ui import components from meshroom.ui.components.clipboard import ClipboardHelper from meshroom.ui.components.filepath import FilepathHelper from meshroom.ui.components.scene3D import Scene3DHelper, Transformations3DHelper from meshroom.ui.components.scriptEditor import ScriptEditorManager from meshroom.ui.components.thumbnail import ThumbnailCache from meshroom.ui.components.messaging import MessageController from meshroom.ui.components.shapes import ShapeFilesHelper, ShapeViewerHelper from meshroom.ui.palette import PaletteManager from meshroom.ui.scene import Scene from meshroom.ui.utils import QmlInstantEngine from meshroom.ui import commands class FileStatus(Enum): MISSING=0 EXISTS=1 ERROR=2 # If the file exists but have errors like missing nodes, file content corruption... class MessageHandler: """ MessageHandler that translates Qt logs to Python logging system. Also contains and filters a list of blacklisted QML warnings that end up in the standard error even when setOutputWarningsToStandardError is set to false on the engine. """ outputQmlWarnings = bool(os.environ.get("MESHROOM_OUTPUT_QML_WARNINGS", False)) logFunctions = { QtMsgType.QtDebugMsg: logging.debug, QtMsgType.QtWarningMsg: logging.warning, QtMsgType.QtInfoMsg: logging.info, QtMsgType.QtFatalMsg: logging.fatal, QtMsgType.QtCriticalMsg: logging.critical, QtMsgType.QtSystemMsg: logging.critical } # Warnings known to be inoffensive and related to QML but not silenced # even when 'MESHROOM_OUTPUT_QML_WARNINGS' is set to False qmlWarningsBlacklist = ( 'Failed to download scene at QUrl("")', 'QVariant(Invalid) Please check your QParameters', 'Texture will be invalid for this frame', ) @classmethod def handler(cls, messageType, context, message): """ Message handler remapping Qt logs to Python logging system. """ if not cls.outputQmlWarnings: # If MESHROOM_OUTPUT_QML_WARNINGS is not set and an error in qml files happen we are # left without any output except "QQmlApplicationEngine failed to load component". # This is extremely hard to debug to someone who does not know about # MESHROOM_OUTPUT_QML_WARNINGS beforehand because by default Qml will output errors to # stdout. if "QQmlApplicationEngine failed to load component" in message: logging.warning("Set MESHROOM_OUTPUT_QML_WARNINGS=1 to get a detailed error message.") # discard blacklisted Qt messages related to QML when 'output qml warnings' is not enabled elif any(w in message for w in cls.qmlWarningsBlacklist): return MessageHandler.logFunctions[messageType](message) def createMeshroomParser(args): # Create the main parser with a description parser = argparse.ArgumentParser( prog='meshroom', description='Launch Meshroom UI - The toolbox that connects research, industry and community at large.', add_help=True, formatter_class=argparse.RawTextHelpFormatter, epilog=''' Examples: 1. Open an existing project in Meshroom: meshroom myProject.mg 2. Open a new project in Meshroom with a specific pipeline, import images from a folder and save the project: meshroom -p photogrammetry -i /path/to/images/ --save /path/to/store/the/project.mg 3. Process a pipeline in command line: meshroom_batch -p cameraTracking -i /input/path -o /output/path -s /path/to/store/the/project.mg See 'meshroom_batch -h' for more details. Additional Resources: Website: https://alicevision.org Manual: https://meshroom-manual.readthedocs.io Forum: https://groups.google.com/g/alicevision Tutorials: https://www.youtube.com/c/AliceVisionOrg Contribute: https://github.com/alicevision/Meshroom ''' ) # Positional Arguments parser.add_argument( 'project', metavar='PROJECT', type=str, nargs='?', help='Meshroom project file (e.g. myProject.mg) or folder with images to reconstruct.' ) # General Options general_group = parser.add_argument_group('General Options') general_group.add_argument( '-v', '--verbose', help='Set the verbosity level for logging:\n' ' - fatal: Show only critical errors.\n' ' - error: Show errors only.\n' ' - warning: Show warnings and errors.\n' ' - info: Show standard informational messages.\n' ' - debug: Show detailed debug information.\n' ' - trace: Show all messages, including trace-level details.', default=os.environ.get('MESHROOM_VERBOSE', 'warning'), choices=['fatal', 'error', 'warning', 'info', 'debug', 'trace'], ) general_group.add_argument( '--submitLabel', metavar='SUBMITLABEL', type=str, help='Label of a node when submitted on renderfarm.', default=os.environ.get('MESHROOM_SUBMIT_LABEL', '[Meshroom] {projectName}'), ) # Project and Input Options project_group = parser.add_argument_group('Project and Input Options') project_group.add_argument( '-i', '--import', metavar='IMAGES/FOLDERS', type=str, nargs='*', help='Import images or a folder with images to process.' ) project_group.add_argument( '-I', '--importRecursive', metavar='FOLDERS', type=str, nargs='*', help='Import images to process from specified folder and sub-folders.' ) project_group.add_argument( '-s', '--save', metavar='PROJECT.mg', type=str, required=False, help='Save the created scene to the specified Meshroom project file.' ) project_group.add_argument( '-1', '--latest', action='store_true', help='Load the most recent scene (-2 and -3 to load the previous ones).' ) project_group.add_argument( '-2', '--latest2', action='store_true', help=argparse.SUPPRESS # This hides the option from the help message ) project_group.add_argument( '-3', '--latest3', action='store_true', help=argparse.SUPPRESS # This hides the option from the help message ) project_group.add_argument( '-o', '--output', metavar='OUTPUT_FOLDER', type=str, required=False, nargs='*', help='Set the output folder for the CopyFiles nodes.' ) # Pipeline Options pipeline_group = parser.add_argument_group('Pipeline Options') pipeline_group.add_argument( '-p', '--pipeline', metavar='FILE.mg / PIPELINE', type=str, default=os.environ.get('MESHROOM_DEFAULT_PIPELINE', ''), help='Select the default Meshroom pipeline:\n' + '\n'.join([' - ' + p for p in meshroom.core.pipelineTemplates]), ) advanced_group = parser.add_argument_group("Advanced Options") advanced_group.add_argument( "--env-help", action=EnvVarHelpAction, nargs=0, help=EnvVarHelpAction.DEFAULT_HELP, ) return parser.parse_args(args[1:]) class MeshroomApp(QApplication): """ Meshroom UI Application. """ def __init__(self, inputArgs): meshroom.core.initPipelines() args = createMeshroomParser(inputArgs) qtArgs = [] if EnvVar.get(EnvVar.MESHROOM_QML_DEBUG): debuggerParams = EnvVar.get(EnvVar.MESHROOM_QML_DEBUG_PARAMS) self.debugger = QQmlDebuggingEnabler(printWarning=True) qtArgs = [f"-qmljsdebugger={debuggerParams}"] logging.getLogger().setLevel(meshroom.logStringToPython[args.verbose]) super().__init__(inputArgs[:1] + qtArgs) self.setOrganizationName('AliceVision') self.setApplicationName('Meshroom') self.setApplicationVersion(meshroom.__version_label__) font = self.font() font.setPointSize(9) self.setFont(font) # Use Fusion style by default. QQuickStyle.setStyle("Fusion") pwd = os.path.dirname(__file__) self.setWindowIcon(QIcon(os.path.join(pwd, "img/meshroom.svg"))) # Initialize thumbnail cache: # - read related environment variables # - clean cache directory and make sure it exists on disk ThumbnailCache.initialize() meshroom.core.initPlugins() meshroom.core.initNodes() meshroom.core.initSubmitters() # Initialize the list of recent project files self._recentProjectFiles = self._getRecentProjectFilesFromSettings() # 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 self._updatedRecentProjectFilesThumbnails = True # Register components for QML before instantiating the engine components.registerTypes() # QML engine setup qmlDir = os.path.join(pwd, "qml") url = os.path.join(qmlDir, "main.qml") self.engine = QmlInstantEngine() self.engine.addFilesFromDirectory(qmlDir, recursive=True) self.engine.setWatching(os.environ.get("MESHROOM_INSTANT_CODING", False)) # whether to output qml warnings to stderr (disable by default) self.engine.setOutputWarningsToStandardError(MessageHandler.outputQmlWarnings) if QtCore.__version_info__ < (5, 14, 2): # After 5.14.1, it gets stuck during logging qInstallMessageHandler(MessageHandler.handler) self.engine.addImportPath(qmlDir) # expose available node types that can be instantiated self.engine.rootContext().setContextProperty("_nodeTypes", {n: {"category": pluginManager.getRegisteredNodePlugins()[n].nodeDescriptor.category} for n in sorted(pluginManager.getRegisteredNodePlugins().keys())}) # instantiate the 3D Scene object self._undoStack = commands.UndoStack(self) self._defaultSubmitterName = os.environ.get('MESHROOM_DEFAULT_SUBMITTER', '') self._taskManager = TaskManager(self) self._activeProject = Scene(undoStack=self._undoStack, taskManager=self._taskManager, defaultPipeline=args.pipeline, parent=self) self._activeProject.setSubmitLabel(args.submitLabel) self.engine.rootContext().setContextProperty("_currentScene", self._activeProject) # those helpers should be available from QML Utils module as singletons, but: # - qmlRegisterUncreatableType is not yet available in PySide2 # - declaring them as singleton in qmldir file causes random crash at exit # => expose them as context properties instead self.engine.rootContext().setContextProperty("Filepath", FilepathHelper(parent=self)) self.engine.rootContext().setContextProperty("Scene3DHelper", Scene3DHelper(parent=self)) self.engine.rootContext().setContextProperty("Transformations3DHelper", Transformations3DHelper(parent=self)) self.engine.rootContext().setContextProperty("Clipboard", ClipboardHelper(parent=self)) self.engine.rootContext().setContextProperty("ThumbnailCache", ThumbnailCache(parent=self)) self.engine.rootContext().setContextProperty("ShapeFilesHelper", ShapeFilesHelper(self.activeProject, parent=self)) self.engine.rootContext().setContextProperty("ShapeViewerHelper", ShapeViewerHelper(parent=self)) # additional context properties self._messageController = MessageController(parent=self) self.engine.rootContext().setContextProperty("_messageController", self._messageController) self.engine.rootContext().setContextProperty("_PaletteManager", PaletteManager(self.engine, parent=self)) self.engine.rootContext().setContextProperty("ScriptEditorManager", ScriptEditorManager(parent=self)) self.engine.rootContext().setContextProperty("MeshroomApp", self) # request any potential computation to stop on exit self.aboutToQuit.connect(self._activeProject.stopChildThreads) if args.project and not os.path.isfile(args.project): raise RuntimeError( "Meshroom Command Line Error: 'PROJECT' argument should be a Meshroom project file (.mg).\n" "Invalid value: '{}'".format(args.project)) if args.project: args.project = os.path.abspath(args.project) self._activeProject.load(args.project) self.addRecentProjectFile(args.project) elif args.latest or args.latest2 or args.latest3: projects = self._recentProjectFiles if projects: index = [args.latest, args.latest2, args.latest3].index(True) project = os.path.abspath(projects[index]["path"]) self._activeProject.load(project) self.addRecentProjectFile(project) elif getattr(args, "import", None) or args.importRecursive or args.save or args.pipeline: if args.output: # Initialize the template and keep the "CopyFiles" nodes self._activeProject.newWithCopyOutputs() # Use the provided output paths to initialize the "CopyFiles" nodes copyNodes = self._activeProject.graph.nodesOfType("CopyFiles") if len(copyNodes) > 0: for index, node in enumerate(copyNodes): node.output.value = args.output[index] if index < len(args.output) else args.output[0] else: self._activeProject.new() # import is a python keyword, so we have to access the attribute by a string if getattr(args, "import", None): self._activeProject.importImagesFromFolder(getattr(args, "import"), recursive=False) if args.importRecursive: self._activeProject.importImagesFromFolder(args.importRecursive, recursive=True) if args.save: if os.path.isfile(args.save): raise RuntimeError( "Meshroom Command Line Error: Cannot save the new Meshroom project as the file (.mg) already exists.\n" "Invalid value: '{}'".format(args.save)) projectFolder = os.path.dirname(args.save) if not os.path.isdir(projectFolder): if not os.path.isdir(os.path.dirname(projectFolder)): raise RuntimeError( "Meshroom Command Line Error: Cannot save the new Meshroom project file (.mg) as the parent of the folder does not exists.\n" "Invalid value: '{}'".format(args.save)) os.mkdir(projectFolder) self._activeProject.saveAs(args.save) self.addRecentProjectFile(args.save) self.engine.load(os.path.normpath(url)) def terminateManual(self): self.engine.clearComponentCache() self.engine.collectGarbage() self.engine.deleteLater() def _pipelineTemplateFiles(self): templates = [] for key in sorted(meshroom.core.pipelineTemplates.keys()): # Use uppercase letters in the names as separators to format the templates' name nicely # e.g: the template "panoramaHdr" will be shown as "Panorama Hdr" in the menu name = " ".join(re.findall('[A-Z][^A-Z]*', key[0].upper() + key[1:])) variant = {"name": name, "key": key, "path": meshroom.core.pipelineTemplates[key]} templates.append(variant) return templates def _pipelineTemplateNames(self): return [p["name"] for p in self.pipelineTemplateFiles] @Slot() def reloadTemplateList(self): meshroom.core.initPipelines() self.pipelineTemplateFilesChanged.emit() @Slot() def forceUIUpdate(self): """ Force UI to process pending events Necessary when we want to update the UI while a trigger is still running (e.g. reloadPlugins) """ self.processEvents() def showMessage(self, message, status=None, duration=5000): self._messageController.sendMessage(message, status, duration) def _retrieveThumbnailPath(self, filepath: str) -> str: """ Given the path of a project file, load this file and try to retrieve the path to its thumbnail, i.e. its first viewpoint image. Args: filepath: the path of the project file to retrieve the thumbnail from Returns: The path to the thumbnail if it could be found, an empty string otherwise """ try: with open(filepath) as file: fileData = json.load(file) graphData = fileData.get("graph", {}) for node in graphData.values(): if node.get("nodeType") != "CameraInit": continue if viewpoints := node.get("inputs", {}).get("viewpoints"): return viewpoints[0].get("path", "") except FileNotFoundError: logging.info(f"File {filepath} not found on disk.") except (json.JSONDecodeError, UnicodeDecodeError): logging.info(f"Error while loading file {filepath}.") except KeyError as err: logging.info(f"The following key does not exist: {str(err)}") except Exception as err: logging.info(f"Exception: {str(err)}") return "" def _getRecentProjectFilesFromSettings(self) -> list[dict[str, str]]: """ Read the list of recent project files from the QSettings, retrieve their filepath, and if it exists, their thumbnail. Returns: The list containing dictionaries of the form {"path": "/path/to/project/file", "thumbnail": "/path/to/thumbnail"} based on the recent projects stored in the QSettings. """ projects = [] settings = QSettings() settings.beginGroup("RecentFiles") size = settings.beginReadArray("Projects") 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 p = {"path": path, "thumbnail": self._retrieveThumbnailPath(path), "status": fileStatus.value} projects.append(p) settings.endArray() settings.endGroup() return projects @Slot() def updateRecentProjectFilesThumbnails(self) -> None: """ If there are thumbnails that may be retrievable (meaning the list of projects has been updated minimally), update the list of recent project files by reading the QSettings and retrieving the thumbnails if they are available. """ if not self._updatedRecentProjectFilesThumbnails: self._updateRecentProjectFilesThumbnails() self._updatedRecentProjectFilesThumbnails = True def _updateRecentProjectFilesThumbnails(self) -> None: for project in self._recentProjectFiles: path = project["path"] project["thumbnail"] = self._retrieveThumbnailPath(path) project["status"] = os.path.isfile(path) @Slot(str) @Slot(QUrl) def addRecentProjectFile(self, projectFile) -> None: """ Add a project file to the list of recent project files. The function ensures that the file is not present more than once in the list and trims it so it never exceeds a set number of projects. QSettings are updated accordingly. The update of the list of recent projects files is minimal: the filepath is added, but there is no attempt to retrieve its corresponding thumbnail. 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() # Get the list of recent projects without re-reading the QSettings projects = self._recentProjectFiles # Checks whether the path is already in the list of recent projects filepaths = [p["path"] for p in projects] if projectFileNorm in filepaths: idx = filepaths.index(projectFileNorm) del projects[idx] # If so, delete its entry # Insert the newest entry at the top of the list projects.insert(0, {"path": projectFileNorm, "thumbnail": "", "status": FileStatus.EXISTS}) # Only keep the first 40 projects maxNbProjects = 40 if len(projects) > maxNbProjects: projects = projects[0:maxNbProjects] # Update the general settings settings = QSettings() settings.beginGroup("RecentFiles") settings.beginWriteArray("Projects") for i, p in enumerate(projects): settings.setArrayIndex(i) settings.setValue("filepath", p["path"]) settings.endArray() settings.endGroup() settings.sync() # Update the final list of recent projects self._recentProjectFiles = projects self._updatedRecentProjectFilesThumbnails = False # Thumbnails may not be up-to-date self.recentProjectFilesChanged.emit() @Slot(str) @Slot(QUrl) def removeRecentProjectFile(self, projectFile) -> None: """ Remove a given project file from the list of recent project files. 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() # Get the list of recent projects without re-reading the QSettings projects = self._recentProjectFiles # Ensure the filepath is in the list of recent projects filepaths = [p["path"] for p in projects] if projectFileNorm not in filepaths: return # Delete it from the list idx = filepaths.index(projectFileNorm) del projects[idx] # Update the general settings settings = QSettings() settings.beginGroup("RecentFiles") settings.beginWriteArray("Projects") for i, p in enumerate(projects): settings.setArrayIndex(i) settings.setValue("filepath", p["path"]) settings.endArray() settings.sync() # Update the final list of recent projects self._recentProjectFiles = projects self.recentProjectFilesChanged.emit() def _recentImportedImagesFolders(self): folders = [] settings = QSettings() settings.beginGroup("RecentFiles") size = settings.beginReadArray("ImagesFolders") for i in range(size): settings.setArrayIndex(i) f = settings.value("path") if f: folders.append(f) settings.endArray() return folders @Slot(QUrl) def addRecentImportedImagesFolder(self, imagesFolder): if isinstance(imagesFolder, QUrl): folderPath = imagesFolder.toLocalFile() if not folderPath: folderPath = imagesFolder.toString() else: raise TypeError(f"Unexpected data type: {imagesFolder.__class__}") folders = self._recentImportedImagesFolders() # remove duplicates while preserving order from collections import OrderedDict uniqueFolders = OrderedDict.fromkeys(folders) folders = list(uniqueFolders) # remove previous usage of the value if folderPath in uniqueFolders: folders.remove(folderPath) # add the new value in the first place folders.insert(0, folderPath) # keep only the first three elements to have a backup if one of the folders goes missing folders = folders[0:3] settings = QSettings() settings.beginGroup("RecentFiles") settings.beginWriteArray("ImagesFolders") for i, p in enumerate(folders): settings.setArrayIndex(i) settings.setValue("path", p) settings.endArray() settings.sync() self.recentImportedImagesFoldersChanged.emit() @Slot(QUrl) def removeRecentImportedImagesFolder(self, imagesFolder): if isinstance(imagesFolder, QUrl): folderPath = imagesFolder.toLocalFile() if not folderPath: folderPath = imagesFolder.toString() else: raise TypeError(f"Unexpected data type: {imagesFolder.__class__}") folders = self._recentImportedImagesFolders() # remove duplicates while preserving order from collections import OrderedDict uniqueFolders = OrderedDict.fromkeys(folders) folders = list(uniqueFolders) # remove previous usage of the value if folderPath not in uniqueFolders: return folders.remove(folderPath) settings = QSettings() settings.beginGroup("RecentFiles") settings.beginWriteArray("ImagesFolders") for i, f in enumerate(folders): settings.setArrayIndex(i) settings.setValue("path", f) settings.endArray() settings.sync() self.recentImportedImagesFoldersChanged.emit() @Slot(str, result=str) def markdownToHtml(self, md): """ Convert markdown to HTML. Args: md (str): the markdown text to convert Returns: str: the resulting HTML string """ try: from markdown import markdown except ImportError: logging.warning("Can't import markdown module, returning source markdown text.") return md return markdown(md) def _systemInfo(self): import platform import sys return { 'platform': f'{platform.system()} {platform.release()}', 'python': f"Python {sys.version.split(' ')[0]}", 'pyside': f'PySide6 {PySideVersion}' } systemInfo = Property(QJsonValue, _systemInfo, constant=True) def _changelogModel(self): """ Get the complete changelog for the application. Model provides: title: the name of the changelog localUrl: the local path to CHANGES.md onlineUrl: the remote path to CHANGES.md """ rootDir = os.environ.get("MESHROOM_INSTALL_DIR", os.getcwd()) return [ { "title": "Changelog", "localUrl": os.path.join(rootDir, "CHANGES.md"), "onlineUrl": "https://raw.githubusercontent.com/alicevision/meshroom/develop/CHANGES.md" } ] def _licensesModel(self): """ Get info about open-source licenses for the application. Model provides: title: the name of the project localUrl: the local path to COPYING.md onlineUrl: the remote path to COPYING.md """ rootDir = os.environ.get("MESHROOM_INSTALL_DIR", os.getcwd()) return [ { "title": "Meshroom", "localUrl": os.path.join(rootDir, "COPYING.md"), "onlineUrl": "https://raw.githubusercontent.com/alicevision/meshroom/develop/COPYING.md" }, { "title": "AliceVision", "localUrl": os.path.join(rootDir, "aliceVision", "share", "aliceVision", "COPYING.md"), "onlineUrl": "https://raw.githubusercontent.com/alicevision/AliceVision/develop/COPYING.md" } ] def _default8bitViewerEnabled(self): return self._getEnvironmentVariableValue("MESHROOM_USE_8BIT_VIEWER", False) def _defaultSequencePlayerEnabled(self): return self._getEnvironmentVariableValue("MESHROOM_USE_SEQUENCE_PLAYER", True) def _getEnvironmentVariableValue(self, key: str, defaultValue: bool) -> bool: """ Fetch the value of a provided environment variable if it exists, and ensure it is correctly evaluated. Args: key: the key for the environment variable defaultValue: the value to use if the key does not exist """ val = os.environ.get(key, defaultValue) # os.environ.get returns a string if the key exists, no matter its value, and converting a # string to a bool always evaluates to "True" if val != True and str(val).lower() in ("0", "false", "off"): return False return True def _submittersList(self): """ Get the list of available submitters Model provides : name : the name of the submitter isDefault : True if this is the current submitter """ submittersList = [] for i, s in enumerate(meshroom.core.submitters): submitterName = s.name if isinstance(s, BaseSubmitter) else s # If no explicit default submitter, this will be the first one isDefault = (i == 0) if self._defaultSubmitterName: isDefault = (submitterName == self._defaultSubmitterName) submittersList.append({ "name": submitterName, "isDefault": isDefault }) return submittersList @Slot(str) def setDefaultSubmitter(self, name): logging.info(f"Submitter is now set to : {name}") self._defaultSubmitterName = name activeProjectChanged = Signal() activeProject = Property(Variant, lambda self: self._activeProject, notify=activeProjectChanged) changelogModel = Property("QVariantList", _changelogModel, constant=True) licensesModel = Property("QVariantList", _licensesModel, constant=True) pipelineTemplateFilesChanged = Signal() recentProjectFilesChanged = Signal() recentImportedImagesFoldersChanged = Signal() pipelineTemplateFiles = Property("QVariantList", _pipelineTemplateFiles, notify=pipelineTemplateFilesChanged) pipelineTemplateNames = Property("QVariantList", _pipelineTemplateNames, notify=pipelineTemplateFilesChanged) recentProjectFiles = Property("QVariantList", lambda self: self._recentProjectFiles, notify=recentProjectFilesChanged) recentImportedImagesFolders = Property("QVariantList", _recentImportedImagesFolders, notify=recentImportedImagesFoldersChanged) default8bitViewerEnabled = Property(bool, _default8bitViewerEnabled, constant=True) defaultSequencePlayerEnabled = Property(bool, _defaultSequencePlayerEnabled, constant=True) submittersListModel = Property("QVariantList", _submittersList, constant=True) ================================================ FILE: meshroom/ui/commands.py ================================================ import logging import traceback from contextlib import contextmanager from PySide6.QtGui import QUndoCommand, QUndoStack from PySide6.QtCore import Property, Signal from meshroom.core.attribute import ListAttribute, Attribute from meshroom.core.exception import CyclicDependencyError,InvalidEdgeError from meshroom.core.graph import Graph, GraphModification from meshroom.core.node import Position, CompatibilityIssue from meshroom.core.nodeFactory import nodeFactory from meshroom.core.mtyping import PathLike class UndoCommand(QUndoCommand): def __init__(self, parent=None): super().__init__(parent) self._enabled = True def setEnabled(self, enabled): self._enabled = enabled def redo(self): if not self._enabled: return try: self.redoImpl() except Exception: logging.error(f"Error while redoing command '{self.text()}': \n{traceback.format_exc()}") def undo(self): if not self._enabled: return try: self.undoImpl() except Exception: logging.error(f"Error while undoing command '{self.text()}': \n{traceback.format_exc()}") def redoImpl(self): # type: () -> bool pass def undoImpl(self): # type: () -> bool pass class UndoStack(QUndoStack): def __init__(self, parent=None): super().__init__(parent) # connect QUndoStack signal to UndoStack's ones self.cleanChanged.connect(self._cleanChanged) self.canUndoChanged.connect(self._canUndoChanged) self.canRedoChanged.connect(self._canRedoChanged) self.undoTextChanged.connect(self._undoTextChanged) self.redoTextChanged.connect(self._redoTextChanged) self.indexChanged.connect(self._indexChanged) self._undoableIndex = 0 # used to block the undo stack while computing self._lockedRedo = False # used to avoid unwanted behaviors while computing def tryAndPush(self, command): # type: (UndoCommand) -> bool try: res = command.redoImpl() except Exception: logging.error(f"Error while trying command '{command.text()}': \n{traceback.format_exc()}") res = False if res is not False: command.setEnabled(False) self.push(command) # takes ownership self.setLockedRedo(False) # make sure to unlock the redo action command.setEnabled(True) return res def setUndoableIndex(self, value): if self._undoableIndex == value: return self._undoableIndex = value self.isUndoableIndexChanged.emit() def setLockedRedo(self, value): if self._lockedRedo == value: return self._lockedRedo = value self.lockedRedoChanged.emit() def lockAtThisIndex(self): """ Lock the undo stack at the current index and lock the redo action. Note: should be used while starting a new compute to avoid problems. """ self.setUndoableIndex(self.index) self.setLockedRedo(True) def unlock(self): """ Unlock both undo stack and redo action. """ self.setUndoableIndex(0) self.setLockedRedo(False) # Redeclare QUndoStack signal since original ones can not be used for properties notifying _cleanChanged = Signal() _canUndoChanged = Signal() _canRedoChanged = Signal() _undoTextChanged = Signal() _redoTextChanged = Signal() _indexChanged = Signal() clean = Property(bool, QUndoStack.isClean, notify=_cleanChanged) canUndo = Property(bool, QUndoStack.canUndo, notify=_canRedoChanged) canRedo = Property(bool, QUndoStack.canRedo, notify=_canUndoChanged) undoText = Property(str, QUndoStack.undoText, notify=_undoTextChanged) redoText = Property(str, QUndoStack.redoText, notify=_redoTextChanged) index = Property(int, QUndoStack.index, notify=_indexChanged) isUndoableIndexChanged = Signal() isUndoableIndex = Property(bool, lambda self: self.index > self._undoableIndex, notify=isUndoableIndexChanged) lockedRedoChanged = Signal() lockedRedo = Property(bool, lambda self: self._lockedRedo, setLockedRedo, notify=lockedRedoChanged) class GraphCommand(UndoCommand): def __init__(self, graph, parent=None): super().__init__(parent) self.graph = graph class AddNodeCommand(GraphCommand): def __init__(self, graph, nodeType, position, parent=None, **kwargs): super().__init__(graph, parent) self.nodeType = nodeType self.nodeName = None self.position = position self.kwargs = kwargs # Serialize Attributes as link expressions for key, value in self.kwargs.items(): if isinstance(value, Attribute): self.kwargs[key] = value.asLinkExpr() elif isinstance(value, list): for idx, v in enumerate(value): if isinstance(v, Attribute): value[idx] = v.asLinkExpr() def redoImpl(self): node = self.graph.addNewNode(self.nodeType, position=self.position, **self.kwargs) self.nodeName = node.name self.setText(f"Add Node {self.nodeName}") return node def undoImpl(self): self.graph.removeNode(self.nodeName) class RenameNodeCommand(GraphCommand): def __init__(self, graph, node, name, parent=None): """ Command to rename a node. The new name should not be used yet. """ super().__init__(graph, parent) self.node = node self.oldName = node._name self.name = name def redoImpl(self): self.setText(f"Rename Node {self.oldName} to {self.name}") self.graph.renameNode(self.node, self.name) return self.node._name def undoImpl(self): self.graph.renameNode(self.node, self.oldName) class RemoveNodeCommand(GraphCommand): def __init__(self, graph, node, parent=None): super().__init__(graph, parent) self.nodeDict = node.toDict() self.nodeName = node.getName() self.setText(f"Remove Node {self.nodeName}") self.outEdges = {} self.outListAttributes = {} # maps attribute's key with a tuple containing the name of the list it is connected to and its value def redoImpl(self): # keep outEdges (inEdges are serialized in nodeDict so unneeded here) and outListAttributes to be able to recreate the deleted elements in ListAttributes _, self.outEdges, self.outListAttributes = self.graph.removeNode(self.nodeName) return True def undoImpl(self): with GraphModification(self.graph): node = nodeFactory(self.nodeDict, self.nodeName) self.graph.addNode(node, self.nodeName) assert (node.getName() == self.nodeName) self.graph._restoreOutEdges(self.outEdges, self.outListAttributes) class DuplicateNodesCommand(GraphCommand): """ Handle node duplication in a Graph. """ def __init__(self, graph, srcNodes, parent=None): super().__init__(graph, parent) self.srcNodeNames = [ n.name for n in srcNodes ] self.setText("Duplicate Nodes") def redoImpl(self): srcNodes = [ self.graph.node(i) for i in self.srcNodeNames ] # flatten the list of duplicated nodes to avoid lists within the list duplicates = [ n for nodes in list(self.graph.duplicateNodes(srcNodes).values()) for n in nodes ] self.duplicates = [ n.name for n in duplicates ] return duplicates def undoImpl(self): # remove all duplicates for duplicate in self.duplicates: self.graph.removeNode(duplicate) class PasteNodesCommand(GraphCommand): """ Handle node pasting in a Graph. """ def __init__(self, graph: "Graph", data: dict, position: Position, parent=None): super().__init__(graph, parent) self.data = data self.position = position self.nodeNames: list[str] = [] def redoImpl(self): graph = Graph("") try: graph._deserialize(self.data) except: return False boundingBoxCenter = self._boundingBoxCenter(graph.nodes) offset = Position(self.position.x - boundingBoxCenter.x, self.position.y - boundingBoxCenter.y) for node in graph.nodes: node.position = Position(node.position.x + offset.x, node.position.y + offset.y) nodes = self.graph.importGraphContent(graph) self.nodeNames = [node.name for node in nodes] self.setText(f"Paste Node{'s' if len(self.nodeNames) > 1 else ''} ({', '.join(self.nodeNames)})") return nodes def undoImpl(self): for name in self.nodeNames: self.graph.removeNode(name) def _boundingBox(self, nodes) -> tuple[int, int, int, int]: if not nodes: return (0, 0, 0 , 0) minX = maxX = nodes[0].x minY = maxY = nodes[0].y for node in nodes[1:]: minX = min(minX, node.x) minY = min(minY, node.y) maxX = max(maxX, node.x) maxY = max(maxY, node.y) return (minX, minY, maxX, maxY) def _boundingBoxCenter(self, nodes): minX, minY, maxX, maxY = self._boundingBox(nodes) return Position((minX + maxX) / 2, (minY + maxY) / 2) class ImportProjectCommand(GraphCommand): """ Handle the import of a project into a Graph. """ def __init__(self, graph: Graph, filepath: PathLike, position=None, yOffset=0, parent=None): super().__init__(graph, parent) self.filepath = filepath self.importedNames = [] self.position = position self.yOffset = yOffset def redoImpl(self): importedNodes = self.graph.importGraphContentFromFile(self.filepath) self.setText(f"Import Project ({len(importedNodes)} nodes)") lowestY = 0 for node in self.graph.nodes: if node not in importedNodes and node.y > lowestY: lowestY = node.y for node in importedNodes: self.importedNames.append(node.name) if self.position is not None: self.graph.node(node.name).position = Position(node.x + self.position.x, node.y + self.position.y) else: self.graph.node(node.name).position = Position(node.x, node.y + lowestY + self.yOffset) return importedNodes def undoImpl(self): for nodeName in self.importedNames: self.graph.removeNode(nodeName) self.importedNames = [] class SetAttributeCommand(GraphCommand): def __init__(self, graph, attribute, value, parent=None): super().__init__(graph, parent) self.attrName = attribute.fullName self.value = value self.oldValue = attribute.getSerializedValue() self.setText(f"Set Attribute '{attribute.fullName}'") def redoImpl(self): if self.value == self.oldValue: return False if self.graph.attribute(self.attrName) is not None: self.graph.attribute(self.attrName).value = self.value else: self.graph.internalAttribute(self.attrName).value = self.value return True def undoImpl(self): if self.graph.attribute(self.attrName) is not None: self.graph.attribute(self.attrName).value = self.oldValue else: self.graph.internalAttribute(self.attrName).value = self.oldValue class AddAttributeKeyValueCommand(GraphCommand): def __init__(self, graph, attribute, key, value, parent=None): super().__init__(graph, parent) self.attrName = attribute.fullName self.keyable = attribute.keyable self.key = key self.value = value self.oldValue = None if attribute.keyable and attribute.keyValues.hasKey(key): self.oldValue = attribute.keyValues.pairs.get(int(key)).value self.setText(f"Add (key, value) for attribute '{attribute.fullName}' at key: '{key}'") def redoImpl(self): if not self.keyable or self.value == self.oldValue: return False if self.graph.attribute(self.attrName) is not None: self.graph.attribute(self.attrName).keyValues.add(self.key, self.value) else: self.graph.internalAttribute(self.attrName).keyValues.add(self.key, self.value) return True def undoImpl(self): if not self.keyable or self.value == self.oldValue: return False if self.graph.attribute(self.attrName) is not None: if self.oldValue is None: self.graph.attribute(self.attrName).keyValues.remove(self.key) else: self.graph.attribute(self.attrName).keyValues.add(self.key, self.oldValue) else: if self.oldValue is None: self.graph.internalAttribute(self.attrName).keyValues.remove(self.key) else: self.graph.internalAttribute(self.attrName).keyValues.add(self.key, self.oldValue) return True class RemoveAttributeKeyCommand(GraphCommand): def __init__(self, graph, attribute, key, parent=None): super().__init__(graph, parent) self.attrName = attribute.fullName self.keyable = attribute.keyable self.key = key self.oldValue = None if attribute.keyable and attribute.keyValues.hasKey(key): self.oldValue = attribute.keyValues.pairs.get(int(key)).value self.setText(f"Remove (key, value) for attribute '{attribute.fullName}' at key: '{key}'") def redoImpl(self): if not self.keyable or self.oldValue == None: return False if self.graph.attribute(self.attrName) is not None: self.graph.attribute(self.attrName).keyValues.remove(self.key) else: self.graph.internalAttribute(self.attrName).keyValues.remove(self.key) return True def undoImpl(self): if not self.keyable or self.oldValue == None: return False if self.graph.attribute(self.attrName) is not None: self.graph.attribute(self.attrName).keyValues.add(self.key, self.oldValue) else: self.graph.internalAttribute(self.attrName).keyValues.add(self.key, self.oldValue) return True class SetObservationCommand(GraphCommand): def __init__(self, graph, attribute, key, observation, parent=None): super().__init__(graph, parent) self.attrName = attribute.fullName self.key = key self.observation = observation.toVariant() self.oldObservation = attribute.geometry.getObservation(key) self.setText(f"Set observation for shape attribute '{attribute.fullName}' at key: '{key}'") def redoImpl(self): if self.graph.attribute(self.attrName) is not None: self.graph.attribute(self.attrName).geometry.setObservation(self.key, self.observation) else: self.graph.internalAttribute(self.attrName).geometry.setObservation(self.key, self.observation) return True def undoImpl(self): if self.graph.attribute(self.attrName) is not None: if self.oldObservation is None: self.graph.attribute(self.attrName).geometry.removeObservation(self.key) else: self.graph.attribute(self.attrName).geometry.setObservation(self.key, self.oldObservation) else: if self.oldObservation is None: self.graph.internalAttribute(self.attrName).geometry.removeObservation(self.key) else: self.graph.internalAttribute(self.attrName).geometry.setObservation(self.key, self.oldObservation) return True class RemoveObservationCommand(GraphCommand): def __init__(self, graph, attribute, key, parent=None): super().__init__(graph, parent) self.attrName = attribute.fullName self.key = key self.oldObservation = attribute.geometry.getObservation(key) self.setText(f"Remove observation for shape attribute '{attribute.fullName}' at key: '{key}'") def redoImpl(self): if self.graph.attribute(self.attrName) is not None: self.graph.attribute(self.attrName).geometry.removeObservation(self.key) else: self.graph.internalAttribute(self.attrName).geometry.removeObservation(self.key) return True def undoImpl(self): if self.graph.attribute(self.attrName) is not None: self.graph.attribute(self.attrName).geometry.setObservation(self.key, self.oldObservation) else: self.graph.internalAttribute(self.attrName).geometry.setObservation(self.key, self.oldObservation) return True class AddEdgeCommand(GraphCommand): def __init__(self, graph, src, dst, parent=None): super().__init__(graph, parent) self.srcAttr = src.fullName self.dstAttr = dst.fullName self.createdEdges = [] # List of all the edges that have been created at once self.deletedEdges = [] # List of all the edges that have been deleted to create the new edge(s) self.setText(f"Connect '{self.srcAttr}' -> '{self.dstAttr}'") if not dst.validateIncomingConnection(src): raise InvalidEdgeError(src.fullName, dst.fullName, "Attributes are not compatible.") def redoImpl(self) -> bool: try: self.createdEdges, self.deletedEdges = self.graph.attribute(self.srcAttr).connectTo(self.graph.attribute(self.dstAttr)) except CyclicDependencyError: self.graph.removeEdge(self.graph.attribute(self.dstAttr)) return False return True def undoImpl(self) -> bool: for edge in self.createdEdges: edge[1].disconnectEdge() for edge in self.deletedEdges: edge[0].connectTo(edge[1]) return True class RemoveEdgeCommand(GraphCommand): def __init__(self, graph, edge, parent=None): super().__init__(graph, parent) self.srcAttr = edge.src.fullName self.dstAttr = edge.dst.fullName self.deletedEdges = [] # List of all the edges that have been deleted self.setText(f"Disconnect '{self.srcAttr}' -> '{self.dstAttr}'") def redoImpl(self) -> bool: self.deletedEdges = self.graph.attribute(self.dstAttr).disconnectEdge() return True def undoImpl(self) -> bool: for edge in self.deletedEdges: edge[0].connectTo(edge[1]) return True class ListAttributeAppendCommand(GraphCommand): def __init__(self, graph, listAttribute, value, parent=None): super().__init__(graph, parent) assert isinstance(listAttribute, ListAttribute) self.attrName = listAttribute.fullName self.index = None self.count = 1 self.value = value if value else None self.setText(f"Append to {self.attrName}") def redoImpl(self): listAttribute = self.graph.attribute(self.attrName) self.index = len(listAttribute) if isinstance(self.value, list): listAttribute.extend(self.value) self.count = len(self.value) else: listAttribute.append(self.value) return True def undoImpl(self): listAttribute = self.graph.attribute(self.attrName) listAttribute.remove(self.index, self.count) class ListAttributeRemoveCommand(GraphCommand): def __init__(self, graph, attribute, parent=None): super().__init__(graph, parent) listAttribute = attribute.root assert isinstance(listAttribute, ListAttribute) self.listAttrName = listAttribute.fullName self.index = listAttribute.index(attribute) self.value = attribute.getSerializedValue() self.setText(f"Remove {attribute.fullName}") def redoImpl(self): listAttribute = self.graph.attribute(self.listAttrName) listAttribute.remove(self.index) return True def undoImpl(self): listAttribute = self.graph.attribute(self.listAttrName) listAttribute.insert(self.index, self.value) class RemoveImagesCommand(GraphCommand): def __init__(self, graph, cameraInitNodes, parent=None): super().__init__(graph, parent) self.cameraInits = cameraInitNodes self.viewpoints = { cameraInit.name: cameraInit.attribute("viewpoints").getSerializedValue() for cameraInit in self.cameraInits } self.intrinsics = { cameraInit.name: cameraInit.attribute("intrinsics").getSerializedValue() for cameraInit in self.cameraInits } self.title = f"Remove{' ' if len(self.cameraInits) == 1 else ' All '}Images" self.setText(self.title) def redoImpl(self): for i in range(len(self.cameraInits)): # Reset viewpoints self.cameraInits[i].viewpoints.resetToDefaultValue() self.cameraInits[i].viewpoints.valueChanged.emit() self.cameraInits[i].viewpoints.requestGraphUpdate() # Reset intrinsics self.cameraInits[i].intrinsics.resetToDefaultValue() self.cameraInits[i].intrinsics.valueChanged.emit() self.cameraInits[i].intrinsics.requestGraphUpdate() def undoImpl(self): for cameraInit in self.viewpoints: with GraphModification(self.graph): self.graph.node(cameraInit).viewpoints.value = self.viewpoints[cameraInit] self.graph.node(cameraInit).intrinsics.value = self.intrinsics[cameraInit] class MoveNodeCommand(GraphCommand): """ Move a node to a given position. """ def __init__(self, graph, node, position, parent=None): super().__init__(graph, parent) self.nodeName = node.name self.oldPosition = node.position self.newPosition = position self.setText(f"Move {self.nodeName}") def redoImpl(self): self.graph.node(self.nodeName).position = self.newPosition return True def undoImpl(self): self.graph.node(self.nodeName).position = self.oldPosition class UpgradeNodeCommand(GraphCommand): """ Perform node upgrade on a CompatibilityNode. """ def __init__(self, graph, node, parent=None): super().__init__(graph, parent) self.nodeDict = node.toDict() self.nodeName = node.getName() self.compatibilityIssue = None self.setText(f"Upgrade Node {self.nodeName}") def redoImpl(self): if not (node := self.graph.node(self.nodeName)).canUpgrade: return False self.compatibilityIssue = node.issue return self.graph.upgradeNode(self.nodeName) def undoImpl(self): expectedUid = None if self.compatibilityIssue == CompatibilityIssue.UidConflict: expectedUid = self.graph.node(self.nodeName)._uid # recreate compatibility node with GraphModification(self.graph): node = nodeFactory(self.nodeDict, name=self.nodeName, expectedUid=expectedUid) self.graph.replaceNode(self.nodeName, node) class EnableGraphUpdateCommand(GraphCommand): """ Command to enable/disable graph update. Should not be used directly, use GroupedGraphModification context manager instead. """ def __init__(self, graph, enabled, parent=None): super().__init__(graph, parent) self.enabled = enabled self.previousState = self.graph.updateEnabled def redoImpl(self): self.graph.updateEnabled = self.enabled return True def undoImpl(self): self.graph.updateEnabled = self.previousState @contextmanager def GroupedGraphModification(graph, undoStack, title, disableUpdates=True): """ A context manager that creates a macro command disabling (if not already) graph update by default and resetting its status after nested block execution. Args: graph (Graph): the Graph that will be modified undoStack (UndoStack): the UndoStack to work with title (str): the title of the macro command disableUpdates (bool): whether to disable graph updates """ # Store graph update state state = graph.updateEnabled # Create a new command macro and push a command that disable graph updates undoStack.beginMacro(title) if disableUpdates: undoStack.tryAndPush(EnableGraphUpdateCommand(graph, False)) try: yield # Execute nested block except Exception: raise finally: if disableUpdates: # Push a command restoring graph update state and end command macro undoStack.tryAndPush(EnableGraphUpdateCommand(graph, state)) undoStack.endMacro() ================================================ FILE: meshroom/ui/components/__init__.py ================================================ def registerTypes(): from PySide6.QtQml import qmlRegisterType, qmlRegisterSingletonType from meshroom.ui.components.clipboard import ClipboardHelper from meshroom.ui.components.edge import EdgeMouseArea from meshroom.ui.components.filepath import FilepathHelper from meshroom.ui.components.scene3D import Scene3DHelper, TrackballController, Transformations3DHelper from meshroom.ui.components.csvData import CsvData from meshroom.ui.components.geom2D import Geom2D from meshroom.ui.components.scriptEditor import PySyntaxHighlighter from meshroom.ui.components.logLinesModel import LogLinesModel, LogLevelEnum qmlRegisterType(EdgeMouseArea, "GraphEditor", 1, 0, "EdgeMouseArea") qmlRegisterType(ClipboardHelper, "Meshroom.Helpers", 1, 0, "ClipboardHelper") # TODO: uncreatable qmlRegisterType(FilepathHelper, "Meshroom.Helpers", 1, 0, "FilepathHelper") # TODO: uncreatable qmlRegisterType(Scene3DHelper, "Meshroom.Helpers", 1, 0, "Scene3DHelper") # TODO: uncreatable qmlRegisterType(Transformations3DHelper, "Meshroom.Helpers", 1, 0, "Transformations3DHelper") # TODO: uncreatable qmlRegisterType(TrackballController, "Meshroom.Helpers", 1, 0, "TrackballController") qmlRegisterType(CsvData, "DataObjects", 1, 0, "CsvData") qmlRegisterType(LogLinesModel, "DataObjects", 1, 0, "LogLinesModel") qmlRegisterType(PySyntaxHighlighter, "ScriptEditor", 1, 0, "PySyntaxHighlighter") qmlRegisterSingletonType(Geom2D, "Meshroom.Helpers", 1, 0, "Geom2D") qmlRegisterSingletonType(LogLevelEnum, "DataObjects", 1, 0, "LogLevelEnum") ================================================ FILE: meshroom/ui/components/clipboard.py ================================================ from PySide6.QtCore import Slot, QObject from PySide6.QtGui import QGuiApplication class ClipboardHelper(QObject): """ Simple wrapper around a QClipboard with methods exposed as Slots for QML use. """ def __init__(self, parent=None): super(ClipboardHelper, self).__init__(parent) self._clipboard = QGuiApplication.clipboard() @Slot(str) def setText(self, value): self._clipboard.setText(value) @Slot(result=str) def getText(self): return self._clipboard.text() @Slot() def clear(self): self._clipboard.clear() ================================================ FILE: meshroom/ui/components/csvData.py ================================================ from meshroom.common.qt import QObjectListModel from PySide6.QtCore import QObject, Slot, Signal, Property from PySide6 import QtCharts import csv import os import logging class CsvData(QObject): """Store data from a CSV file.""" def __init__(self, parent=None): """Initialize the object without any parameter.""" super(CsvData, self).__init__(parent=parent) self._filepath = "" self._data = QObjectListModel(parent=self) # List of CsvColumn self._ready = False self.filepathChanged.connect(self.updateData) @Slot(int, result=QObject) def getColumn(self, index): return self._data.at(index) @Slot(result=str) def getFilepath(self): return self._filepath @Slot(result=int) def getNbColumns(self): if self._ready: return len(self._data) else: return 0 @Slot(str) def setFilepath(self, filepath): if self._filepath == filepath: return self.setReady(False) self._filepath = filepath self.filepathChanged.emit() def setReady(self, ready): if self._ready == ready: return self._ready = ready self.readyChanged.emit() @Slot() def updateData(self): self.setReady(False) self._data.clear() newColumns = self.read() if newColumns: self._data.setObjectList(newColumns) self.setReady(True) def read(self): """Read the CSV file and return a list containing CsvColumn objects.""" if not self._filepath or not self._filepath.lower().endswith(".csv") or not os.path.isfile(self._filepath): return [] dataList = [] try: csvRows = [] with open(self._filepath, "r") as fp: reader = csv.reader(fp) for row in reader: csvRows.append(row) # Create the objects in dataList # with the first line elements as objects' title for elt in csvRows[0]: dataList.append(CsvColumn(elt)) # , parent=self._data # Populate the content attribute for elt in csvRows[1:]: for idx, value in enumerate(elt): dataList[idx].appendValue(value) except Exception as exc: logging.error(f"CsvData: Failed to load file: {self._filepath}\n{exc}") return dataList filepathChanged = Signal() filepath = Property(str, getFilepath, setFilepath, notify=filepathChanged) readyChanged = Signal() ready = Property(bool, lambda self: self._ready, notify=readyChanged) data = Property(QObject, lambda self: self._data, notify=readyChanged) nbColumns = Property(int, getNbColumns, notify=readyChanged) class CsvColumn(QObject): """Store content of a CSV column.""" def __init__(self, title="", parent=None): """Initialize the object with optional column title parameter.""" super(CsvColumn, self).__init__(parent=parent) self._title = title self._content = [] def appendValue(self, value): self._content.append(value) @Slot(result=str) def getFirst(self): if not self._content: return "" return self._content[0] @Slot(result=str) def getLast(self): if not self._content: return "" return self._content[-1] @Slot(QtCharts.QXYSeries) def fillChartSerie(self, serie): """Fill XYSerie used for displaying QML Chart.""" if not serie: return serie.clear() for index, value in enumerate(self._content): serie.append(float(index), float(value)) title = Property(str, lambda self: self._title, constant=True) content = Property("QStringList", lambda self: self._content, constant=True) ================================================ FILE: meshroom/ui/components/edge.py ================================================ from PySide6.QtCore import Signal, Property, QPointF, Qt, QObject, Slot, QRectF from PySide6.QtGui import QPainterPath, QVector2D from PySide6.QtQuick import QQuickItem class MouseEvent(QObject): """ Simple MouseEvent object, since QQuickMouseEvent is not accessible in the public API """ def __init__(self, evt): super(MouseEvent, self).__init__() self._x = evt.position().x() self._y = evt.position().y() self._button = evt.button() self._modifiers = evt.modifiers() x = Property(float, lambda self: self._x, constant=True) y = Property(float, lambda self: self._y, constant=True) button = Property(Qt.MouseButton, lambda self: self._button, constant=True) modifiers = Property(Qt.KeyboardModifier, lambda self: self._modifiers, constant=True) class EdgeMouseArea(QQuickItem): """ Provides a MouseArea shaped as a cubic spline for mouse interaction with edges. Spline goes from (0,0) to (width, height). Works with negative values. """ def __init__(self, parent=None): super(EdgeMouseArea, self).__init__(parent) self._curveScale = 0.7 self._thickness = 2.0 self._containsMouse = False self._path = None # type: QPainterPath self.setAcceptHoverEvents(True) self.setAcceptedMouseButtons(Qt.AllButtons) def contains(self, point): return self._path.contains(point) def hoverEnterEvent(self, evt): self.setContainsMouse(True) super(EdgeMouseArea, self).hoverEnterEvent(evt) def hoverLeaveEvent(self, evt): self.setContainsMouse(False) super(EdgeMouseArea, self).hoverLeaveEvent(evt) def geometryChange(self, newGeometry, oldGeometry): super(EdgeMouseArea, self).geometryChange(newGeometry, oldGeometry) self.updateShape() def mousePressEvent(self, evt): if not self.acceptedMouseButtons() & evt.button(): evt.setAccepted(False) return e = MouseEvent(evt) self.pressed.emit(e) e.deleteLater() def mouseReleaseEvent(self, evt): e = MouseEvent(evt) self.released.emit(e) e.deleteLater() def updateShape(self): p1 = QPointF(0, 0) p2 = QPointF(self.width(), self.height()) ctrlPt = QPointF(abs(self.width() * self.curveScale), 0) path = QPainterPath(p1) path.cubicTo(p1 + ctrlPt, p2 - ctrlPt, p2) # Compute offset on x and y axis halfThickness = self._thickness / 2.0 v = QVector2D(p2 - p1).normalized() offset = QPointF(halfThickness * -v.y(), halfThickness * v.x()) self._path = QPainterPath(path.toReversed()) self._path.translate(-offset) path.translate(offset) self._path.connectPath(path) def getThickness(self): return self._thickness def setThickness(self, value): if self._thickness == value: return self._thickness = value self.thicknessChanged.emit() self.updateShape() def getCurveScale(self): return self._curveScale def setCurveScale(self, value): if self.curveScale == value: return self._curveScale = value self.curveScaleChanged.emit() self.updateShape() def getContainsMouse(self): return self._containsMouse def setContainsMouse(self, value): if self._containsMouse == value: return self._containsMouse = value self.containsMouseChanged.emit() @Slot(QPointF, QPointF, result=bool) def intersectsSegment(self, p1, p2): """ Checks whether the given segment (p1, p2) intersects with the Path. """ path = QPainterPath() # Starting point path.moveTo(p1) # Create a diagonal line to the other end of the rect path.lineTo(p2) v = self._path.intersects(path) return v thicknessChanged = Signal() thickness = Property(float, getThickness, setThickness, notify=thicknessChanged) curveScaleChanged = Signal() curveScale = Property(float, getCurveScale, setCurveScale, notify=curveScaleChanged) containsMouseChanged = Signal() containsMouse = Property(float, getContainsMouse, notify=containsMouseChanged) acceptedButtons = Property(int, lambda self: super(EdgeMouseArea, self).acceptedMouseButtons, lambda self, value: super(EdgeMouseArea, self).setAcceptedMouseButtons(Qt.MouseButtons(value))) pressed = Signal(MouseEvent) released = Signal(MouseEvent) ================================================ FILE: meshroom/ui/components/filepath.py ================================================ #!/usr/bin/env python # coding:utf-8 from PySide6.QtCore import QUrl, QFileInfo from PySide6.QtCore import QObject, Slot import os import glob import pyseq class FilepathHelper(QObject): """ FilepathHelper gives access to file path methods not available from JS. It should be non-instantiable and expose only static methods, but this is not yet possible in PySide. """ @staticmethod def asStr(path): """ Accepts strings and QUrls and always returns 'path' as a string. Args: path (str or QUrl): the filepath to consider Returns: str: String representation of 'path' """ if not isinstance(path, (QUrl, str)): raise TypeError(f"Unexpected data type: {path.__class__}") if isinstance(path, QUrl): path = path.toLocalFile() return path @Slot(str, result=str) @Slot(QUrl, result=str) def basename(self, path): """ Returns the final component of a pathname. """ return os.path.basename(self.asStr(path)) @Slot(str, result=str) @Slot(QUrl, result=str) def dirname(self, path): """ Returns the directory component of a pathname. """ return os.path.dirname(self.asStr(path)) @Slot(str, result=str) @Slot(QUrl, result=str) def extension(self, path): """ Returns the extension (.ext) of a pathname. """ return os.path.splitext(self.asStr(path))[-1] @Slot(str, result=str) @Slot(QUrl, result=str) def removeExtension(self, path): """ Returns the pathname without its extension (.ext). """ return os.path.splitext(self.asStr(path))[0] @Slot(str, result=bool) @Slot(QUrl, result=bool) def accessible(self, path): """ Returns whether a path is accessible for the user """ path = self.asStr(path) return os.path.isdir(self.asStr(path)) and os.access(path, os.R_OK) and os.access(path, os.W_OK) @Slot(str, result=bool) @Slot(QUrl, result=bool) def isFile(self, path): """ Test whether a path is a regular file. """ return os.path.isfile(self.asStr(path)) @Slot(str, result=bool) @Slot(QUrl, result=bool) def exists(self, path): """ Test whether a path exists. """ return os.path.exists(self.asStr(path)) @Slot(QUrl, result=str) def urlToString(self, url): """ Convert QUrl to a string using 'QUrl.toLocalFile' method. """ return self.asStr(url) @Slot(str, result=QUrl) def stringToUrl(self, path): """ Convert a path (string) to a QUrl using 'QUrl.fromLocalFile' method. """ return QUrl.fromLocalFile(path) @Slot(str, result=str) @Slot(QUrl, result=str) def normpath(self, path): """ Returns native normalized path. """ return os.path.normpath(self.asStr(path)) @Slot(str, result=str) @Slot(QUrl, result=str) def globFirst(self, path): """ Returns the first from a list of paths matching a pathname pattern. """ import glob fileList = glob.glob(self.asStr(path)) fileList.sort() if fileList: return fileList[0] return "" @Slot(QUrl, result=int) def fileSizeMB(self, path): """ Returns the file size in MB. """ return QFileInfo(self.asStr(path)).size() / (1024*1024) @Slot(str, QObject, result=str) def resolve(self, path, vp): # Resolve dynamic path that depends on viewpoint from meshroom.core import fileUtils if vp == None: replacements = FilepathHelper.getFilenamesFromFolder(FilepathHelper, FilepathHelper.dirname(FilepathHelper, path), FilepathHelper.extension(FilepathHelper, path)) resolved = [path for i in range(len(replacements))] for key in replacements: for i in range(len(resolved)): resolved[i] = resolved[i].replace("", replacements[i]) return resolved return fileUtils.resolvePath(vp, path) @Slot(str, result="QVariantList") @Slot(str, str, result="QVariantList") def getFilenamesFromFolder(self, folderPath: str, extension: str = None): """ Get all filenames from a folder with a specific extension. :param folderPath: Path to the folder. :param extension: Extension of the files to get. :return: List of filenames. """ if extension is None: extension = ".*" return [self.basename(f) for f in glob.glob(os.path.join(folderPath, f"*{extension}")) if os.path.isfile(f)] @Slot(str, bool, result="QVariantList") def resolveSequence(self, path, includesSeqMissingFiles): """ Get id of each file in the sequence. """ # use of pyseq to get the sequences seqs = pyseq.get_sequences(self.asStr(path)) frameRanges = [[seq.start(), seq.end()] for seq in seqs] # create the resolved path for each sequence if includesSeqMissingFiles: resolved = [] for seq in seqs: if not seq.frames(): # In case of a single frame, pyseq does not exctract a frameNumber s = [fileItem.path for fileItem in seq] else: # Create all frames between start and end, even for missing files s = [seq.format("%D%h%p%t") % frameNumber for frameNumber in range(seq.start(), seq.end() + 1)] resolved.append(s) else: resolved = [[fileItem.path for fileItem in seq] for seq in seqs] return frameRanges, resolved ================================================ FILE: meshroom/ui/components/geom2D.py ================================================ from PySide6.QtCore import QObject, Slot, QRectF class Geom2D(QObject): @Slot(QRectF, QRectF, result=bool) def rectRectIntersect(self, rect1: QRectF, rect2: QRectF) -> bool: """ Check if two rectangles intersect. """ return rect1.intersects(rect2) @Slot(QRectF, QRectF, result=bool) def rectRectFullIntersect(self, rect1: QRectF, rect2: QRectF) -> bool: """ Check if two rectangles intersect fully. i.e. rect1 fully holds rect2 inside it.""" intersected = rect1.intersected(rect2) # They do not intersect at all if not intersected: return False # Validate that intersected rect is same as rect2 # If both are same, that implies it fully lies inside of rect1 return intersected == rect2 ================================================ FILE: meshroom/ui/components/logLinesModel.py ================================================ from PySide6.QtCore import QAbstractListModel, Qt, QModelIndex, Slot, QObject, Property import re from enum import IntEnum class LogLevel(IntEnum): """ Enum for log levels. These values can be used in QML for filtering, styling, or conditional logic. """ UNKNOWN = 0 TRACE = 1 DEBUG = 2 INFO = 3 WARNING = 4 ERROR = 5 CRITICAL = 6 FATAL = 7 class LogLevelEnum(QObject): """ Wrapper class to expose LogLevel enum to QML. Usage in QML: import DataObjects 1.0 if (level === LogLevelEnum.ERROR) { // Handle error } """ def __init__(self, parent=None): super().__init__(parent) @Property(int, constant=True) def UNKNOWN(self): return int(LogLevel.UNKNOWN) @Property(int, constant=True) def TRACE(self): return int(LogLevel.TRACE) @Property(int, constant=True) def DEBUG(self): return int(LogLevel.DEBUG) @Property(int, constant=True) def INFO(self): return int(LogLevel.INFO) @Property(int, constant=True) def WARNING(self): return int(LogLevel.WARNING) @Property(int, constant=True) def ERROR(self): return int(LogLevel.ERROR) @Property(int, constant=True) def CRITICAL(self): return int(LogLevel.CRITICAL) @Property(int, constant=True) def FATAL(self): return int(LogLevel.FATAL) class LogLinesModel(QAbstractListModel): """ Model for log lines with duration tracking. This Qt model parses log text and extracts metadata including timestamps, log levels, and calculates the duration (in seconds) between consecutive timestamped log entries. Expected log format: [HH:MM:SS][LEVEL][optional:numbers] message text Example: [12:34:56][INFO][1:23] Application started Each item in the model contains: - line: The message text (without metadata) - time: The timestamp string (HH:MM:SS) - level: The log level as an enum (LogLevel) - duration: Seconds elapsed since the previous timestamped line (-1 if not applicable) Attributes: LineRole: Custom role for accessing the message text LevelRole: Custom role for accessing the log level (as LogLevel enum) TimeRole: Custom role for accessing the timestamp DurationRole: Custom role for accessing the duration between log entries """ # Custom roles for data access LineRole = Qt.UserRole + 1 # Message text LevelRole = Qt.UserRole + 2 # Log level (LogLevel enum) TimeRole = Qt.UserRole + 3 # Timestamp (HH:MM:SS) DurationRole = Qt.UserRole + 4 # Duration in seconds since previous timestamped line # Mapping from string log levels to enum values _LEVEL_MAP = { 'trace': LogLevel.TRACE, 'debug': LogLevel.DEBUG, 'info': LogLevel.INFO, 'warning': LogLevel.WARNING, 'warn': LogLevel.WARNING, 'error': LogLevel.ERROR, 'critical': LogLevel.CRITICAL, 'fatal': LogLevel.FATAL, } def __init__(self, parent=None): """ Initialize the LogLinesModel. Args: parent: Optional parent QObject """ super().__init__(parent) self._lines = [] # List of dictionaries containing parsed log data # Regex pattern to parse log format: [timestamp][level][optional:numbers] message # Groups: 1=time, 2=hours, 3=minutes, 4=seconds, 5=level, 6=optional1, 7=optional2, 8=message self._format_regex = re.compile(r'^\[[^]]*?((\d{2}):(\d{2}):(\d{2}))[^]]*\]\[([A-Za-z]+)\](?:\[(\d+):(\d+)\])?\s*(.*)$') def rowCount(self, parent=QModelIndex()): """ Return the number of rows in the model. Args: parent: Parent index (unused, as this is a flat list model) Returns: int: Number of log lines in the model """ if parent.isValid(): return 0 return len(self._lines) def data(self, index, role=Qt.DisplayRole): """ Retrieve data for a given index and role. Args: index: QModelIndex for the requested item role: The data role being requested Returns: The requested data, or None if invalid index or role """ if not index.isValid() or index.row() >= len(self._lines): return None item = self._lines[index.row()] if role == self.LineRole or role == Qt.DisplayRole: return item["line"] elif role == self.LevelRole: return item["level"] # Returns LogLevel enum value (int) elif role == self.TimeRole: return item["time"] elif role == self.DurationRole: return item["duration"] return None def roleNames(self): """ Define role names for QML access. Returns: dict: Mapping of role IDs to byte-encoded role names """ return { self.LineRole: b"line", self.LevelRole: b"level", self.TimeRole: b"time", self.DurationRole: b"duration" } @Slot(str) def setText(self, text): """ Parse log text and update the model with lines and durations. This method: 1. Splits the input text into lines 2. Parses each line to extract metadata (time, level, message) 3. Calculates duration between consecutive timestamped lines 4. Updates the model with the parsed data Args: text: Multi-line string containing log entries """ self.beginResetModel() self._lines = [] if not text: self.endResetModel() return # Split text into individual lines lines = text.split('\n') # Calculate durations between consecutive timestamped lines prev_seconds = -1 for line in lines: delta = -1 metadata = self.parseMetadata(line) seconds = metadata["seconds"] if seconds >= 0: if prev_seconds >= 0: delta = seconds - prev_seconds prev_seconds = seconds self._lines.append({ "line": metadata["line"], "time": metadata["time"], "level": int(metadata["level"]), "duration": delta }) self.endResetModel() def parseMetadata(self, line): """ Parse a single log line to extract metadata. Expected format: [HH:MM:SS][LEVEL][optional:numbers] message Args: line: A single line of log text Returns: dict: Parsed metadata with keys: - line (str): The message text - time (str): Timestamp in HH:MM:SS format - seconds (int): Total seconds since midnight (for duration calculation) - level (LogLevel): Log level as enum value """ text = line time = "00:00:00" level = LogLevel.INFO seconds = -1 match = self._format_regex.match(line) if match: # Extract matched groups time = match.group(1) # HH:MM:SS level_str = match.group(5).lower() # Log level string text = match.group(8) # Message text # Convert string level to enum level = self._LEVEL_MAP.get(level_str, LogLevel.UNKNOWN) # Convert time to total seconds for duration calculation try: hh = int(match.group(2)) # Hours mm = int(match.group(3)) # Minutes ss = int(match.group(4)) # Seconds seconds = ss + 60 * mm + 3600 * hh except ValueError: # If conversion fails, keep seconds at -1 (Sentinel value) pass return { "line": text, "time": time, "seconds": seconds, "level": level } @Slot(result=int) def count(self): """ Return the number of lines in the model. This is a convenience method for QML compatibility. Returns: int: Number of log lines """ return len(self._lines) @Slot(int, result='QVariant') def get(self, index): """ Get the item at the specified index. This method provides QML-style access similar to ListModel.get(). Args: index: The index of the item to retrieve Returns: dict: The item data if index is valid, None otherwise """ if 0 <= index < len(self._lines): return self._lines[index] return None @Slot() def clear(self): """ Clear all lines from the model. This removes all log entries and resets the model to an empty state. """ self.beginResetModel() self._lines = [] self.endResetModel() @Slot(int, result=str) def levelToString(self, level): """ Convert a LogLevel enum value to its string representation. Useful in QML for displaying log level names. Args: level: LogLevel enum value Returns: str: String representation of the log level """ try: return LogLevel(level).name except ValueError: return "UNKNOWN" ================================================ FILE: meshroom/ui/components/messaging.py ================================================ import json from PySide6.QtCore import QObject from datetime import datetime from meshroom.common import Signal, Slot, Property class Message: def __init__(self, msg, status=None): self.msg = msg self.status = status or "info" self.date = datetime.now() def dateStr(self, fullDate=False): dateFormat = "%H:%M:%S" if fullDate: dateFormat = "%Y-%m-%d %H:%M:%S.%f" return self.date.strftime(dateFormat) class MessageController(QObject): """ Handles messages sent from the Python side to the StatusBar component. """ message = Signal(str, str, int) messagesChanged = Signal() # Signal to notify when messages list changes def __init__(self, parent): super().__init__(parent) self._messages = [] def sendMessage(self, msg, status, duration): """ Sends a message that will be displayed on the status bar. """ self.message.emit(msg, status, duration) @Slot(str, str) def storeMessage(self, msg, status): """ Adds a new message in the stack. """ self._messages.append(Message(msg, status or "info")) self.messagesChanged.emit() # Notify QML that messages have changed def _getMessagesDict(self, fullDate=False): """ Get a dict with all stored messages. """ messages = [] for msg in self._messages: messages.append({ "status": msg.status, "date": msg.dateStr(fullDate), "text": msg.msg, }) return messages def getMessages(self): """ Get the messages with simple date information. Reverse the list to make sure we see the most recent item on top """ return self._getMessagesDict()[::-1] @Slot(result=str) def getMessagesAsString(self): """ Return messages for clipboard copy. .. note:: Could also do `json.dumps(self._getMessagesDict(fullDate=True), indent=4)` """ messages = [] for msg in self._messages: messages.append(f"{msg.dateStr(True)} [{msg.status.upper():<7}] {msg.msg}") return "\n".join(messages) @Slot() def clearMessages(self): """ Clear all stored messages. """ self._messages.clear() self.messagesChanged.emit() # Property to expose messages to QML messages = Property("QVariantList", getMessages, notify=messagesChanged) ================================================ FILE: meshroom/ui/components/scene3D.py ================================================ from math import acos, pi, sqrt, atan2, cos, sin, asin from PySide6.QtCore import QObject, Slot, QSize, Signal, QPointF from PySide6.Qt3DCore import Qt3DCore from PySide6.Qt3DRender import Qt3DRender from PySide6.QtGui import QVector3D, QQuaternion, QVector2D, QVector4D, QMatrix4x4 from meshroom.ui.utils import makeProperty class Scene3DHelper(QObject): @Slot(Qt3DCore.QEntity, str, result="QVariantList") def findChildrenByProperty(self, entity, propertyName): """ Recursively get all children of an entity that have a property named 'propertyName'. """ children = [] for child in entity.childNodes(): try: if child.metaObject().indexOfProperty(propertyName) != -1: children.append(child) except RuntimeError: continue children += self.findChildrenByProperty(child, propertyName) return children @Slot(Qt3DCore.QEntity, Qt3DCore.QComponent) def addComponent(self, entity, component): """ Adds a component to an entity. """ entity.addComponent(component) @Slot(Qt3DCore.QEntity, Qt3DCore.QComponent) def removeComponent(self, entity, component): """ Removes a component from an entity. """ entity.removeComponent(component) @Slot(Qt3DCore.QEntity, result=int) def vertexCount(self, entity): """ Return vertex count based on children QGeometryRenderer 'vertexCount'. """ return sum([renderer.vertexCount() for renderer in entity.findChildren(Qt3DRender.QGeometryRenderer)]) @Slot(Qt3DCore.QEntity, result=int) def faceCount(self, entity): """ Returns face count based on children QGeometry buffers size. """ count = 0 for geo in entity.findChildren(Qt3DCore.QGeometry): count += sum([attr.count() for attr in geo.attributes() if attr.name() == "vertexPosition"]) return count / 3 @Slot(Qt3DCore.QEntity, result=int) def vertexColorCount(self, entity): count = 0 for geo in entity.findChildren(Qt3DCore.QGeometry): count += sum([attr.count() for attr in geo.attributes() if attr.name() == "vertexColor"]) return count class TrackballController(QObject): """ Trackball-like camera controller. Based on the C++ version from https://github.com/cjmdaixi/Qt3DTrackball """ def __init__(self, parent=None): super().__init__(parent) self._windowSize = QSize() self._camera = None self._trackballSize = 1.0 self._rotationSpeed = 5.0 def projectToTrackball(self, screenCoords): sx = screenCoords.x() sy = self._windowSize.height() - screenCoords.y() p2d = QVector2D(sx / self._windowSize.width() - 0.5, sy / self._windowSize.height() - 0.5) z = 0.0 r2 = pow(self._trackballSize, 2) lengthSquared = p2d.lengthSquared() if lengthSquared <= r2 * 0.5: z = sqrt(r2 - lengthSquared) else: z = r2 * 0.5 / p2d.length() return QVector3D(p2d.x(), p2d.y(), z) @staticmethod def clamp(x): return max(-1, min(x, 1)) def createRotation(self, firstPoint, nextPoint): lastPos3D = self.projectToTrackball(firstPoint).normalized() currentPos3D = self.projectToTrackball(nextPoint).normalized() angle = acos(self.clamp(QVector3D.dotProduct(currentPos3D, lastPos3D))) direction = QVector3D.crossProduct(currentPos3D, lastPos3D) return angle, direction @Slot(QPointF, QPointF, float) def rotate(self, lastPosition, currentPosition, dt): angle, direction = self.createRotation(lastPosition, currentPosition) rotatedAxis = self._camera.transform().rotation().rotatedVector(direction) angle *= self._rotationSpeed * dt self._camera.rotateAboutViewCenter(QQuaternion.fromAxisAndAngle(rotatedAxis, angle * pi * 180)) windowSizeChanged = Signal() windowSize = makeProperty(QSize, '_windowSize', windowSizeChanged) cameraChanged = Signal() camera = makeProperty(QObject, '_camera', cameraChanged) trackballSizeChanged = Signal() trackballSize = makeProperty(float, '_trackballSize', trackballSizeChanged) rotationSpeedChanged = Signal() rotationSpeed = makeProperty(float, '_rotationSpeed', rotationSpeedChanged) class Transformations3DHelper(QObject): # ---------- Exposed to QML ---------- # @Slot(QVector3D, QVector3D, result=QQuaternion) def rotationBetweenAandB(self, A, B): A = A/A.length() B = B/B.length() # Get rotation matrix between 2 vectors v = QVector3D.crossProduct(A, B) s = v.length() c = QVector3D.dotProduct(A, B) return QQuaternion.fromAxisAndAngle(v / s, atan2(s, c) * 180 / pi) @Slot(QVector3D, result=QVector3D) def fromEquirectangular(self, vector): return QVector3D(cos(vector.x()) * sin(vector.y()), sin(vector.x()), cos(vector.x()) * cos(vector.y())) @Slot(QVector3D, result=QVector3D) def toEquirectangular(self, vector): return QVector3D(asin(vector.y()), atan2(vector.x(), vector.z()), 0) @Slot(QVector3D, QVector2D, QVector2D, result=QVector3D) def updatePanorama(self, euler, ptStart, ptEnd): delta = 1e-3 # Get initial rotation qStart = QQuaternion.fromEulerAngles(euler.y(), euler.x(), euler.z()) # Convert input to points on unit sphere vStart = self.fromEquirectangular(QVector3D(ptStart)) vStartdY = self.fromEquirectangular(QVector3D(ptStart.x(), ptStart.y() + delta, 0)) vEnd = self.fromEquirectangular(QVector3D(ptEnd)) qAdd = QQuaternion.rotationTo(vStart, vEnd) # Get the 3D point on unit sphere which would correspond to the no rotation +X vCurrent = qAdd.rotatedVector(vStartdY) vIdeal = self.fromEquirectangular(QVector3D(ptEnd.x(), ptEnd.y() + delta, 0)) # Project on rotation plane lambdaEnd = 1 / QVector3D.dotProduct(vEnd, vCurrent) lambdaIdeal = 1 / QVector3D.dotProduct(vEnd, vIdeal) vPlaneCurrent = lambdaEnd * vCurrent vPlaneIdeal = lambdaIdeal * vIdeal # Get the directions rotStart = (vPlaneCurrent - vEnd).normalized() rotEnd = (vPlaneIdeal - vEnd).normalized() # Get rotation matrix between 2 vectors v = QVector3D.crossProduct(rotEnd, rotStart) s = QVector3D.dotProduct(v, vEnd) c = QVector3D.dotProduct(rotStart, rotEnd) angle = atan2(s, c) * 180 / pi qImage = QQuaternion.fromAxisAndAngle(vEnd, -angle) return (qImage * qAdd * qStart).toEulerAngles() @Slot(QVector3D, QVector2D, QVector2D, result=QVector3D) def updatePanoramaInPlane(self, euler, ptStart, ptEnd): delta = 1e-3 # Get initial rotation qStart = QQuaternion.fromEulerAngles(euler.y(), euler.x(), euler.z()) # Convert input to points on unit sphere vStart = self.fromEquirectangular(QVector3D(ptStart)) vEnd = self.fromEquirectangular(QVector3D(ptEnd)) # Get the 3D point on unit sphere which would correspond to the no rotation +X vIdeal = self.fromEquirectangular(QVector3D(ptStart.x(), ptStart.y() + delta, 0)) # Project on rotation plane lambdaEnd = 1 / QVector3D.dotProduct(vStart, vEnd) lambdaIdeal = 1 / QVector3D.dotProduct(vStart, vIdeal) vPlaneEnd = lambdaEnd * vEnd vPlaneIdeal = lambdaIdeal * vIdeal # Get the directions rotStart = (vPlaneEnd - vStart).normalized() rotEnd = (vPlaneIdeal - vStart).normalized() # Get rotation matrix between 2 vectors v = QVector3D.crossProduct(rotEnd, rotStart) s = QVector3D.dotProduct(v, vStart) c = QVector3D.dotProduct(rotStart, rotEnd) angle = atan2(s, c) * 180 / pi qAdd = QQuaternion.fromAxisAndAngle(vStart, angle) return (qAdd * qStart).toEulerAngles() @Slot(QVector4D, Qt3DRender.QCamera, QSize, result=QVector2D) def pointFromWorldToScreen(self, point, camera, windowSize): """ Compute the Screen point corresponding to a World Point. Args: point (QVector4D): point in world coordinates camera (QCamera): camera viewing the scene windowSize (QSize): size of the Scene3D window Returns: QVector2D: point in screen coordinates """ # Transform the point from World Coord to Normalized Device Coord viewMatrix = camera.transform().matrix().inverted() projectedPoint = (camera.projectionMatrix() * viewMatrix[0]).map(point) projectedPoint2D = QVector2D( projectedPoint.x()/projectedPoint.w(), projectedPoint.y()/projectedPoint.w() ) # Transform the point from Normalized Device Coord to Screen Coord screenPoint2D = QVector2D( int((projectedPoint2D.x() + 1) * windowSize.width() / 2), int((projectedPoint2D.y() - 1) * windowSize.height() / -2) ) return screenPoint2D @Slot(Qt3DCore.QTransform, QMatrix4x4, QMatrix4x4, QMatrix4x4, QVector3D) def relativeLocalTranslate(self, transformQtInstance, initialPosMat, initialRotMat, initialScaleMat, translateVec): """ Translate the QTransform in its local space relatively to an initial state. Args: transformQtInstance (QTransform): reference to the Transform to modify initialPosMat (QMatrix4x4): initial position matrix initialRotMat (QMatrix4x4): initial rotation matrix initialScaleMat (QMatrix4x4): initial scale matrix translateVec (QVector3D): vector used for the local translation """ # Compute the translation transformation matrix translationMat = QMatrix4x4() translationMat.translate(translateVec) # Compute the new model matrix (POSITION * ROTATION * TRANSLATE * SCALE) and set it to the Transform mat = initialPosMat * initialRotMat * translationMat * initialScaleMat transformQtInstance.setMatrix(mat) @Slot(Qt3DCore.QTransform, QMatrix4x4, QQuaternion, QMatrix4x4, QVector3D, int) def relativeLocalRotate(self, transformQtInstance, initialPosMat, initialRotQuat, initialScaleMat, axis, degree): """ Rotate the QTransform in its local space relatively to an initial state. Args: transformQtInstance (QTransform): reference to the Transform to modify initialPosMat (QMatrix4x4): initial position matrix initialRotQuat (QQuaternion): initial rotation quaternion initialScaleMat (QMatrix4x4): initial scale matrix axis (QVector3D): axis to rotate around degree (int): angle of rotation in degree """ # Compute the transformation quaternion from axis and angle in degrees transformQuat = QQuaternion.fromAxisAndAngle(axis, degree) # Compute the new rotation quaternion and then calculate the matrix newRotQuat = initialRotQuat * transformQuat # Order is important newRotationMat = self.quaternionToRotationMatrix(newRotQuat) # Compute the new model matrix (POSITION * NEW_COMPUTED_ROTATION * SCALE) and set it to the Transform mat = initialPosMat * newRotationMat * initialScaleMat transformQtInstance.setMatrix(mat) @Slot(Qt3DCore.QTransform, QMatrix4x4, QMatrix4x4, QMatrix4x4, QVector3D) def relativeLocalScale(self, transformQtInstance, initialPosMat, initialRotMat, initialScaleMat, scaleVec): """ Scale the QTransform in its local space relatively to an initial state. Args: transformQtInstance (QTransform): reference to the Transform to modify initialPosMat (QMatrix4x4): initial position matrix initialRotMat (QMatrix4x4): initial rotation matrix initialScaleMat (QMatrix4x4): initial scale matrix scaleVec (QVector3D): vector used for the relative scale """ # Make a copy of the scale matrix (otherwise, it is a reference and it does not work as expected) scaleMat = self.copyMatrix4x4(initialScaleMat) # Update the scale matrix copy (X then Y then Z) with the scaleVec values scaleVecTuple = scaleVec.toTuple() for i in range(3): currentRow = list(scaleMat.row(i).toTuple()) # QVector3D does not implement [] operator or easy way to access value by index so this little hack is required value = currentRow[i] + scaleVecTuple[i] value = value if value >= 0 else -value # Make sure to have only positive scale (because negative scale can make issues with matrix decomposition) currentRow[i] = value scaleMat.setRow(i, QVector4D(currentRow[0], currentRow[1], currentRow[2], currentRow[3])) # Apply the new row to the scale matrix # Compute the new model matrix (POSITION * ROTATION * SCALE) and set it to the Transform mat = initialPosMat * initialRotMat * scaleMat transformQtInstance.setMatrix(mat) @Slot(QMatrix4x4, result="QVariant") def modelMatrixToMatrices(self, modelMat): """ Decompose a model matrix into individual matrices. Args: modelMat (QMatrix4x4): model matrix to decompose Returns: QVariant: object containing position, rotation and scale matrices + rotation quaternion """ decomposition = self.decomposeModelMatrix(modelMat) posMat = QMatrix4x4() posMat.translate(decomposition.get("translation")) rotMat = self.quaternionToRotationMatrix(decomposition.get("quaternion")) scaleMat = QMatrix4x4() scaleMat.scale(decomposition.get("scale")) return {"position": posMat, "rotation": rotMat, "scale": scaleMat, "quaternion": decomposition.get("quaternion")} @Slot(QVector3D, QVector3D, QVector3D, result=QMatrix4x4) def computeModelMatrixWithEuler(self, translation, rotation, scale): """ Compute a model matrix from three Vector3D. Args: translation (QVector3D): position in space (x, y, z) rotation (QVector3D): Euler angles in degrees (x, y, z) scale (QVector3D): scale of the object (x, y, z) Returns: QMatrix4x4: corresponding model matrix """ posMat = QMatrix4x4() posMat.translate(translation) quaternion = QQuaternion.fromEulerAngles(rotation) rotMat = self.quaternionToRotationMatrix(quaternion) scaleMat = QMatrix4x4() scaleMat.scale(scale) modelMat = posMat * rotMat * scaleMat return modelMat @Slot(QVector3D, result=QVector3D) def convertRotationFromCV2GL(self, rotation): """ Convert rotation (euler angles) from Computer Vision to Computer Graphics coordinate system (like OpenGL). """ M = QQuaternion.fromAxisAndAngle(QVector3D(1, 0, 0), 180.0) quaternion = QQuaternion.fromEulerAngles(rotation) U = M * quaternion * M return U.toEulerAngles() @Slot(QVector3D, QVector3D, float, float, result=QVector3D) def getRotatedCameraViewVector(self, camereViewVector, cameraUpVector, pitch, yaw): """ Compute the rotated camera view vector with given pitch and yaw (in degrees). Args: camereViewVector (QVector3D): Camera view vector, the displacement from the camera position to its target cameraUpVector (QVector3D): Camera up vector, the direction the top of the camera is facing pitch (float): Rotation pitch (in degrees) yaw (float): Rotation yaw (in degrees) Returns: QVector3D: rotated camera view vector """ cameraSideVector = QVector3D.crossProduct(camereViewVector, cameraUpVector) yawRot = QQuaternion.fromAxisAndAngle(cameraUpVector, yaw) pitchRot = QQuaternion.fromAxisAndAngle(cameraSideVector, pitch) return (yawRot * pitchRot).rotatedVector(camereViewVector) @Slot(QVector3D, QMatrix4x4, Qt3DRender.QCamera, QSize, result=float) def computeScaleUnitFromModelMatrix(self, axis, modelMat, camera, windowSize): """ Compute the length of the screen projected vector axis unit transformed by the model matrix. Args: axis (QVector3D): chosen axis ((1,0,0) or (0,1,0) or (0,0,1)) modelMat (QMatrix4x4): model matrix used for the transformation camera (QCamera): camera viewing the scene windowSize (QSize): size of the window in pixels Returns: float: length (in pixels) """ decomposition = self.decomposeModelMatrix(modelMat) posMat = QMatrix4x4() posMat.translate(decomposition.get("translation")) rotMat = self.quaternionToRotationMatrix(decomposition.get("quaternion")) unitScaleModelMat = posMat * rotMat * QMatrix4x4() worldCenterPoint = unitScaleModelMat.map(QVector4D(0,0,0,1)) worldAxisUnitPoint = unitScaleModelMat.map(QVector4D(axis.x(),axis.y(),axis.z(),1)) screenCenter2D = self.pointFromWorldToScreen(worldCenterPoint, camera, windowSize) screenAxisUnitPoint2D = self.pointFromWorldToScreen(worldAxisUnitPoint, camera, windowSize) screenVector = QVector2D(screenAxisUnitPoint2D.x() - screenCenter2D.x(), -(screenAxisUnitPoint2D.y() - screenCenter2D.y())) value = screenVector.length() return value if (value and value > 10) else 10 # Threshold to avoid problems in extreme case # ---------- "Private" Methods ---------- # def copyMatrix4x4(self, mat): """ Make a deep copy of a QMatrix4x4. """ newMat = QMatrix4x4() for i in range(4): newMat.setRow(i, mat.row(i)) return newMat def decomposeModelMatrix(self, modelMat): """ Decompose a model matrix into individual component. Args: modelMat (QMatrix4x4): model matrix to decompose Returns: QVariant: object containing translation and scale vectors + rotation quaternion """ translation = modelMat.column(3).toVector3D() quaternion = QQuaternion.fromDirection(modelMat.column(2).toVector3D(), modelMat.column(1).toVector3D()) scale = QVector3D(modelMat.column(0).length(), modelMat.column(1).length(), modelMat.column(2).length()) return {"translation": translation, "quaternion": quaternion, "scale": scale} def quaternionToRotationMatrix(self, q): """ Return a rotation matrix from a quaternion. """ rotMat3x3 = q.toRotationMatrix() return QMatrix4x4( rotMat3x3(0, 0), rotMat3x3(0, 1), rotMat3x3(0, 2), 0, rotMat3x3(1, 0), rotMat3x3(1, 1), rotMat3x3(1, 2), 0, rotMat3x3(2, 0), rotMat3x3(2, 1), rotMat3x3(2, 2), 0, 0, 0, 0, 1 ) ================================================ FILE: meshroom/ui/components/scriptEditor.py ================================================ """ Script Editor for Meshroom. """ # STD from io import StringIO from contextlib import redirect_stdout import traceback # Qt from PySide6 import QtCore, QtGui from PySide6.QtCore import Property, QObject, Slot, Signal, QSettings class ScriptEditorManager(QObject): """ Manages the script editor history and logs. """ _GROUP = "ScriptEditor" _KEY = "script" def __init__(self, parent=None): super(ScriptEditorManager, self).__init__(parent=parent) self._history = [] self._index = -1 self._globals = {} self._locals = {} # Protected def _defaultScript(self): """ Returns the default script for the script editor. """ lines = ( "from meshroom.ui import uiInstance\n", "graph = uiInstance.activeProject.graph", "for node in graph.nodes:", " print(node.name)" ) return "\n".join(lines) def _lastScript(self): """ Returns the last script from the user settings. """ settings = QSettings() settings.beginGroup(self._GROUP) return settings.value(self._KEY) def _hasPreviousScript(self): """ Returns whether there is a previous script available. """ # If the current index is greater than the first return self._index > 0 def _hasNextScript(self): """ Returns whether there is a new script available to load. """ # If the current index is lower than the available indexes return self._index < (len(self._history) - 1) # Public @Slot(str, result=str) def process(self, script): """ Execute the provided input script, capture the output from the standard output, and return it. """ # Saves the state if an exception has occurred exception = False stdout = StringIO() with redirect_stdout(stdout): try: exec(script, self._globals, self._locals) except Exception: # Update that we have an exception that is thrown exception = True # Print the backtrace traceback.print_exc(file=stdout) result = stdout.getvalue().strip() # Strip out additional part if exception: # We know that we are executing the above statement and that caused the exception # What we want to show to the user is just the part that happened while executing the script # So just split with the last part and show it to the user result = result.split("self._locals)", 1)[-1] # Add the script to the history and move up the index to the top of history stack self._history.append(script) self._index = len(self._history) self.scriptIndexChanged.emit() return result @Slot() def clearHistory(self): """ Clear the list of executed scripts and reset the index. """ self._history = [] self._index = -1 @Slot(result=str) def getNextScript(self): """ Get the next entry in the history of executed scripts and update the index adequately. If there is no next entry, return an empty string. """ if self._index + 1 < len(self._history) and len(self._history) > 0: self._index = self._index + 1 self.scriptIndexChanged.emit() return self._history[self._index] return "" @Slot(result=str) def getPreviousScript(self): """ Get the previous entry in the history of executed scripts and update the index adequately. If there is no previous entry, return an empty string. """ if self._index - 1 >= 0 and self._index - 1 < len(self._history): self._index = self._index - 1 self.scriptIndexChanged.emit() return self._history[self._index] elif self._index == 0 and len(self._history): return self._history[self._index] return "" @Slot(result=str) def loadLastScript(self): """ Returns the last executed script from the prefs. """ return self._lastScript() or self._defaultScript() @Slot(str) def saveScript(self, script): """ Returns the last executed script from the prefs. Args: script (str): The script to save. """ settings = QSettings() settings.beginGroup(self._GROUP) settings.setValue(self._KEY, script) settings.sync() scriptIndexChanged = Signal() hasPreviousScript = Property(bool, _hasPreviousScript, notify=scriptIndexChanged) hasNextScript = Property(bool, _hasNextScript, notify=scriptIndexChanged) class CharFormat(QtGui.QTextCharFormat): """ The Char format for the syntax. """ def __init__(self, color, bold=False, italic=False): """ Constructor. """ super().__init__() self._color = QtGui.QColor() self._color.setNamedColor(color) # Update the Foreground color self.setForeground(self._color) # The font characteristics if bold: self.setFontWeight(QtGui.QFont.Bold) if italic: self.setFontItalic(True) class PySyntaxHighlighter(QtGui.QSyntaxHighlighter): """ Syntax highlighter for the Python language. """ # Syntax styles that can be shared by all languages STYLES = { "keyword" : CharFormat("#9e59b3"), # Purple "operator" : CharFormat("#2cb8a0"), # Teal "brace" : CharFormat("#2f807e"), # Dark Aqua "defclass" : CharFormat("#c9ba49", bold=True), # Yellow "deffunc" : CharFormat("#4996c9", bold=True), # Blue "string" : CharFormat("#7dbd39"), # Greeny "comment" : CharFormat("#8d8d8d", italic=True), # Dark Grayish "self" : CharFormat("#e6ba43", italic=True), # Yellow "numbers" : CharFormat("#d47713"), # Orangish } # Python keywords keywords = ( "and", "assert", "break", "class", "continue", "def", "del", "elif", "else", "except", "exec", "finally", "for", "from", "global", "if", "import", "in", "is", "lambda", "not", "or", "pass", "print", "raise", "return", "try", "while", "yield", "None", "True", "False", ) # Python operators operators = ( "=", # Comparison "==", "!=", "<", "<=", ">", ">=", # Arithmetic r"\+", "-", r"\*", "/", "//", r"\%", r"\*\*", # In-place r"\+=", "-=", r"\*=", "/=", r"\%=", # Bitwise r"\^", r"\|", r"\&", r"\~", r">>", r"<<", ) # Python braces braces = (r"\{", r"\}", r"\(", r"\)", r"\[", r"\]") def __init__(self, parent=None): """ Constructor. Keyword Args: parent (QObject): The QObject parent from the QML side. """ super().__init__(parent) # The Document to highlight self._document = None # Build a QRegularExpression for each of the pattern self._rules = self.__rules() # Private def __rules(self): """ Formatting rules. """ # Set of rules accordind to which the highlight should occur rules = [] # Keyword rules rules += [(QtCore.QRegularExpression(r"\b" + w + r"\s"), 0, PySyntaxHighlighter.STYLES["keyword"]) for w in PySyntaxHighlighter.keywords] # Operator rules rules += [(QtCore.QRegularExpression(o), 0, PySyntaxHighlighter.STYLES["operator"]) for o in PySyntaxHighlighter.operators] # Braces rules += [(QtCore.QRegularExpression(b), 0, PySyntaxHighlighter.STYLES["brace"]) for b in PySyntaxHighlighter.braces] # All other rules rules += [ # self (QtCore.QRegularExpression(r'\bself\b'), 0, PySyntaxHighlighter.STYLES["self"]), # 'def' followed by an identifier (QtCore.QRegularExpression(r'\bdef\b\s*(\w+)'), 1, PySyntaxHighlighter.STYLES["deffunc"]), # 'class' followed by an identifier (QtCore.QRegularExpression(r'\bclass\b\s*(\w+)'), 1, PySyntaxHighlighter.STYLES["defclass"]), # Numeric literals (QtCore.QRegularExpression(r'\b[+-]?[0-9]+[lL]?\b'), 0, PySyntaxHighlighter.STYLES["numbers"]), (QtCore.QRegularExpression(r'\b[+-]?0[xX][0-9A-Fa-f]+[lL]?\b'), 0, PySyntaxHighlighter.STYLES["numbers"]), (QtCore.QRegularExpression(r'\b[+-]?[0-9]+(?:\.[0-9]+)?(?:[eE][+-]?[0-9]+)?\b'), 0, PySyntaxHighlighter.STYLES["numbers"]), # Double-quoted string, possibly containing escape sequences (QtCore.QRegularExpression(r'"[^"\\]*(\\.[^"\\]*)*"'), 0, PySyntaxHighlighter.STYLES["string"]), # Single-quoted string, possibly containing escape sequences (QtCore.QRegularExpression(r"'[^'\\]*(\\.[^'\\]*)*'"), 0, PySyntaxHighlighter.STYLES["string"]), # From '#' until a newline (QtCore.QRegularExpression(r'#[^\n]*'), 0, PySyntaxHighlighter.STYLES['comment']), ] return rules def highlightBlock(self, text): """ Applies syntax highlighting to the given block of text. Args: text (str): The text to highlight. """ # Do other syntax formatting for expression, nth, _format in self._rules: # fetch the index of the expression in text match = expression.match(text, 0) index = match.capturedStart() while index >= 0: # We actually want the index of the nth match index = match.capturedStart(nth) length = len(match.captured(nth)) self.setFormat(index, length, _format) # index = expression.indexIn(text, index + length) match = expression.match(text, index + length) index = match.capturedStart() def textDoc(self): """ Returns the document being highlighted. """ return self._document def setTextDocument(self, document): """ Sets the document on the Highlighter. Args: document (QtQuick.QQuickTextDocument): The document from the QML engine. """ # If the same document is provided again if document == self._document: return # Update the class document self._document = document # Set the document on the highlighter self.setDocument(self._document.textDocument()) # Emit that the document is now changed self.textDocumentChanged.emit() # Signals textDocumentChanged = Signal() # Property textDocument = Property(QObject, textDoc, setTextDocument, notify=textDocumentChanged) ================================================ FILE: meshroom/ui/components/shapes/__init__.py ================================================ from .shapeFilesHelper import ( ShapeFilesHelper ) from .shapeViewerHelper import ( ShapeViewerHelper ) ================================================ FILE: meshroom/ui/components/shapes/shapeFile.py ================================================ from meshroom.common import BaseObject, Property, Variant, Signal, ListModel, Slot from meshroom.core.attribute import Attribute import json, os, re class ShapeFile(BaseObject): """ List of shapes provided by a json file attribute. """ class ShapeData(BaseObject): """ Single shape with its properties and observations. """ def __init__(self, name: str, type: str, properties={}, observations={}, parent=None): super().__init__(parent) # View id self._viewId = "-1" # Shape name self._name = name # Shape type (Point2d, Line2d, Rectangle, Circle, etc.) self._type = type # Shape properties (color, stroke, etc.) self._properties = properties # Shape observations {viewId: observation{x, y, radius, etc.}} self._observations = observations # Shape keyabale self._keyable = len(observations) > 0 # Shape visible self._visible = True def _getVisible(self) -> bool: """ Return whether the shape is visible for display. """ return self._visible def _setVisible(self, visible:bool): """ Set the shape visibility for display. """ self._visible = visible self.visibleChanged.emit() def setViewId(self, viewId: str): """ Set the shape current view id. """ self._viewId = viewId self.viewIdChanged.emit() def _getObservation(self): """ Get the shape current observation. """ if self._keyable: return self._observations.get(self._viewId, None) return self._properties def _getNbObservations(self): """ Return the shape number of observations. """ if self._keyable: return len(self._observations) return 1 @Slot(str, result=bool) def hasObservation(self, key: str) -> bool: """ Return whether the shape has an observation for the given key. """ if self._keyable: return self._observations.get(self._viewId, None) is not None return True # Signals viewIdChanged = Signal() visibleChanged = Signal() # Properties # The shape name. name = Property(str, lambda self: self._name, constant=True) # The shape label. label = Property(str, lambda self: self._name, constant=True) # The shape type (Point2d, Line2d, Rectangle, Circle, etc.). type = Property(str, lambda self: self._type, constant=True) # The shape properties (color, stroke, etc.). properties = Property(Variant, lambda self: self._properties, constant=True) # The shape current observation. observation = Property(Variant, _getObservation, notify=viewIdChanged) # Whether the shape is keyabale (multiple observations). observationKeyable = Property(bool,lambda self: self._keyable, constant=True) # The shape list of observation keys. observationKeys = Property(Variant, lambda self: [key for key in self._observations], constant=True) # The number of observation defined. nbObservations = Property(int, _getNbObservations, constant=True) # Whether the shape is displayable. isVisible = Property(bool, _getVisible, _setVisible, notify=visibleChanged) def __init__(self, fileAttribute: Attribute, viewId: str, parent=None): super().__init__(parent) # List of shapes self._shapes = ListModel(parent=self) # File attribute self._fileAttribute = fileAttribute # Current view id self._viewId = viewId # Shapes visible self._visible = True # Populate the model from the provided file self._loadShapesFromJsonFile() # Update viewId for all shapes self.setViewId(viewId) # Connect file attribute value fileAttribute.valueChanged.connect(self._loadShapesFromJsonFile) def _getVisible(self) -> bool: """ Return whether the shape file is visible for display. """ return self._visible def _setVisible(self, visible:bool): """ Set the shape file visibility for display. """ self._visible = visible for shape in self._shapes: shape.isVisible = visible self.visibleChanged.emit() def _getBasename(self) -> str: """ Get file attribute basename. """ return os.path.basename(self._fileAttribute.value) def setViewId(self, viewId: str): """ Set the current view id for all shapes of the file. """ for shape in self._shapes: shape.setViewId(viewId) @Slot() def _loadShapesFromJsonFile(self): """ Load shapes from the json file. """ def convertNumericStrings(obj): """ Helper function to convert numeric strings. """ if isinstance(obj, dict): return {k: convertNumericStrings(v) for k, v in obj.items()} elif isinstance(obj, list): return [convertNumericStrings(elem) for elem in obj] elif isinstance(obj, str): # Check for int or float if re.fullmatch(r'-?\d+', obj): return int(obj) elif re.fullmatch(r'-?\d+\.\d*', obj): return float(obj) return obj # Clear model self._shapes.clear() # Load from json file if os.path.exists(self._fileAttribute.value): try: with open(self._fileAttribute.value, "r") as f: # Load json loadedData = json.load(f) # Handle both formats: direct array or object with "shapes" key if isinstance(loadedData, dict) and "shapes" in loadedData: shapesArray = loadedData["shapes"] elif isinstance(loadedData, list): shapesArray = loadedData else: print("Invalid JSON format: expected array or object with 'shapes' key") self.fileChanged.emit() return # Build shapes from proper shapes array for itemData in convertNumericStrings(shapesArray): name = itemData.get("name", "unknown") type = itemData.get("type", "unknown") properties = itemData.get("properties", {}) observations = itemData.get("observations", {}) self._shapes.append(ShapeFile.ShapeData(name, type, properties, observations, self._shapes)) except FileNotFoundError: print("No shapes found to load.") except json.JSONDecodeError as err: print(f"Error decoding JSON: {err}") except Exception as exc: print(f"Error loading shapes: {exc}") self.fileChanged.emit() # Signals fileChanged = Signal() visibleChanged = Signal() # Properties # The model type, always ShapeFile. type = Property(str, lambda self: "ShapeFile", constant=True) # The corresponding File attribute label. label = Property(str, lambda self: self._fileAttribute.label, constant=True) # The file basename. basename = Property(str, _getBasename, notify=fileChanged) # The list of shapes. shapes = Property(Variant, lambda self: self._shapes, notify=fileChanged) # Whether the file has shapes. isEmpty = Property(bool, lambda self: len(self._shapes) <= 0, notify=fileChanged) # Whether the file is displayable. isVisible = Property(bool, _getVisible, _setVisible, notify=visibleChanged) ================================================ FILE: meshroom/ui/components/shapes/shapeFilesHelper.py ================================================ from meshroom.ui.scene import Scene from meshroom.common import BaseObject, Property, Variant, Signal, ListModel, Slot from meshroom.core.attribute import GroupAttribute, ListAttribute from shiboken6 import isValid from .shapeFile import ShapeFile # Filter runtime warning when closing Meshroom with active shape files import warnings warnings.filterwarnings("ignore", message=".*Failed to disconnect.*", category=RuntimeWarning) class ShapeFilesHelper(BaseObject): """ Manages active project selected node shape files. """ def __init__(self, activeProject:Scene, parent=None): super().__init__(parent) self._activeProject = activeProject self._currentNode = activeProject.selectedNode self._shapeFiles = ListModel() self._activeProject.selectedViewIdChanged.connect(self._onSelectedViewIdChanged) self._activeProject.selectedNodeChanged.connect(self._onSelectedNodeChanged) def _loadShapeFilesFromAttributes(self, attributes): """ Search for File attribute with shape file semantic in selected node attributes. Update the model based on the shape files found. """ for attribute in attributes: if isinstance(attribute, (ListAttribute, GroupAttribute)): self._loadShapeFilesFromAttributes(attribute.value) elif attribute.type == "File" and attribute.desc.semantic == "shapeFile": self._shapeFiles.append(ShapeFile(fileAttribute=attribute, viewId=self._activeProject.selectedViewId, parent=self._shapeFiles)) @Slot() def _loadShapeFiles(self): """Load/Reload active project selected node shape files.""" # clear shapeFiles model self._shapeFiles.clear() # load node shape files if self._activeProject.selectedNode: self._loadShapeFilesFromAttributes(self._activeProject.selectedNode.attributes) self.nodeShapeFilesChanged.emit() @Slot() def _onSelectedViewIdChanged(self): """Callback when the active project selected view id changes.""" for shapeFile in self._shapeFiles: shapeFile.setViewId(self._activeProject.selectedViewId) @Slot() def _onSelectedNodeChanged(self): """Callback when the active project selected node changes.""" # disconnect internalFolderChanged signal if self._currentNode is not None: try: self._currentNode.internalFolderChanged.disconnect(self._loadShapeFiles) except RuntimeError: # Signal was already disconnected or never connected pass # check selected node exists and selected node has displayable shape if self._activeProject.selectedNode is None or not self._activeProject.selectedNode.hasDisplayableShape: # clear shapeFiles model if isValid(self._shapeFiles): self._shapeFiles.clear() # clear current node self._currentNode = None return # update current node self._currentNode = self._activeProject.selectedNode # connect internalFolderChanged signal try: self._currentNode.internalFolderChanged.connect(self._loadShapeFiles) except RuntimeError: # Signal was already disconnected or never connected pass # load node shape files self._loadShapeFiles() # Properties and signals nodeShapeFilesChanged = Signal() nodeShapeFiles = Property(Variant, lambda self: self._shapeFiles, notify=nodeShapeFilesChanged) ================================================ FILE: meshroom/ui/components/shapes/shapeViewerHelper.py ================================================ from meshroom.common import BaseObject, Property, Variant, Signal, Slot class ShapeViewerHelper(BaseObject): """ Manages interactions with the qml ShapeViewer (2d Viewer). - Handle shape selection. - Handle shape observation initialization. """ def __init__(self, parent=None): super().__init__(parent) self._selectedShapeName = "" self._containerWidth = 0.0 self._containerHeight = 0.0 self._containerScale = 0.0 def _getSelectedShapeName(self) -> str: return self._selectedShapeName def _getContainerWidth(self) -> float: return self._containerWidth def _getContainerHeight(self) -> float: return self._containerHeight def _getContainerScale(self) -> float: return self._containerScale def _setSelectedShapeName(self, shapeName:str): self._selectedShapeName = shapeName self.selectedShapeNameChanged.emit() def _setContainerWidth(self, width: float): self._containerWidth = width self.containerWidthChanged.emit() def _setContainerHeight(self, height: float): self._containerHeight= height self.containerHeightChanged.emit() def _setContainerScale(self, scale: float): self._containerScale = scale self.containerScaleChanged.emit() @Slot(str, result=Variant) def getDefaultObservation(self, shapeType: str) -> Variant: """ Helper function to create a shape default observation. """ match shapeType: case "Point2d": return { "x": self._containerWidth * 0.5, "y": self._containerHeight * 0.5} case "Line2d": return { "a": { "x": self._containerWidth * 0.4, "y": self._containerHeight * 0.4}, "b": { "x": self._containerWidth * 0.6, "y": self._containerHeight * 0.6}} case "Circle": return { "center": {"x": self._containerWidth * 0.5, "y": self._containerHeight * 0.5}, "radius": self._containerWidth * 0.1} case "Rectangle": return { "center": { "x": self._containerWidth * 0.5, "y": self._containerHeight * 0.5}, "size": { "width": self._containerWidth * 0.2, "height": self._containerHeight * 0.2}} return None # Properties and signals selectedShapeNameChanged = Signal() selectedShapeName = Property(str, _getSelectedShapeName, _setSelectedShapeName, notify=selectedShapeNameChanged) containerWidthChanged = Signal() containerWidth = Property(float, _getContainerWidth, _setContainerWidth, notify=containerWidthChanged) containerHeightChanged = Signal() containerHeight = Property(float, _getContainerHeight, _setContainerHeight, notify=containerHeightChanged) containerScaleChanged = Signal() containerScale = Property(float, _getContainerScale, _setContainerScale, notify=containerScaleChanged) ================================================ FILE: meshroom/ui/components/thumbnail.py ================================================ from meshroom.common import Signal from PySide6.QtCore import QObject, Slot, QSize, QUrl, Qt, QStandardPaths from PySide6.QtGui import QImageReader, QImageWriter import os from pathlib import Path import stat import hashlib import time import logging from threading import Thread from multiprocessing.pool import ThreadPool class ThumbnailCache(QObject): """ThumbnailCache provides an abstraction for the thumbnail cache on disk, available in QML. For a given image file, it ensures the corresponding thumbnail exists (by creating it if necessary) and gives access to it. Since creating thumbnails can be long (as it requires to read the full image from disk) it is performed asynchronously to avoid blocking the main thread. The default cache location can be overriden with the MESHROOM_THUMBNAIL_DIR environment variable. This class also takes care of cleaning the thumbnail directory, i.e. scanning this directory and removing thumbnails that have not been used for too long. This operation also ensures that the number of thumbnails on disk does not exceed a certain limit, by removing thumbnails if necessary (from least recently used to most recently used). Since this operation is done at application startup, it is also performed asynchronously. The default time limit is 90 days, and can be overriden with the MESHROOM_THUMBNAIL_TIME_LIMIT environment variable. The default maximum number of thumbnails on disk is 100000, and can be overriden with the MESHROOM_MAX_THUMBNAILS_ON_DISK. The main use case for thumbnails in Meshroom is in the ImageGallery. """ # Thumbnail cache directory # Cannot be initialized here as it depends on the organization and application names thumbnailDir = '' # Thumbnail dimensions limit (the actual dimensions of a thumbnail will depend on the aspect ratio) thumbnailSize = QSize(256, 256) # Time limit for thumbnail storage on disk, expressed in days storageTimeLimit = 90 # Maximum number of thumbnails in the cache directory maxThumbnailsOnDisk = 100000 # Signal to notify listeners that a thumbnail was created and written on disk # This signal has two argument: # - the url of the image that the thumbnail is associated to # - an identifier for the caller, e.g. the component that sent the request (useful for faster dispatch in QML) thumbnailCreated = Signal(QUrl, int) # Threads info and LIFO structure for running clean and createThumbnail asynchronously requests = [] cleaningThread = None workerThreads = ThreadPool(processes=3) def __del__(self): self.workerThreads.terminate() self.workerThreads.join() @staticmethod def initialize(): """Initialize static fields in cache class and cache directory on disk.""" # Thumbnail directory: default or user specified dir = os.getenv('MESHROOM_THUMBNAIL_DIR') if dir is not None: ThumbnailCache.thumbnailDir = dir else: ThumbnailCache.thumbnailDir = os.path.join(QStandardPaths.writableLocation(QStandardPaths.CacheLocation), 'thumbnails') # User specifed time limit for thumbnails on disk (expressed in days) timeLimit = os.getenv('MESHROOM_THUMBNAIL_TIME_LIMIT') if timeLimit is not None: ThumbnailCache.storageTimeLimit = float(timeLimit) # User specifed maximum number of thumbnails on disk maxOnDisk = os.getenv('MESHROOM_MAX_THUMBNAILS_ON_DISK') if maxOnDisk is not None: ThumbnailCache.maxThumbnailsOnDisk = int(maxOnDisk) # Clean thumbnail directory # This is performed asynchronously to avoid freezing the app at startup ThumbnailCache.cleaningThread = Thread(target=ThumbnailCache.clean) ThumbnailCache.cleaningThread.start() # Make sure the thumbnail directory exists before writing into it try: os.makedirs(ThumbnailCache.thumbnailDir, exist_ok=True) except OSError: logging.warning(f'[ThumbnailCache] Failed to create directory: {ThumbnailCache.thumbnailDir}') pass @staticmethod def clean(): """Scan the thumbnail directory and: 1. remove all thumbnails that have not been used for more than storageTimeLimit days 2. ensure that the number of thumbnails on disk does not exceed maxThumbnailsOnDisk. """ # Check if thumbnail directory exists if not os.path.exists(ThumbnailCache.thumbnailDir): logging.debug('[ThumbnailCache] Thumbnail directory does not exist yet.') return # Get current time now = time.time() # Scan thumbnail directory and gather all thumbnails to remove toRemove = [] remaining = [] for f_name in os.listdir(ThumbnailCache.thumbnailDir): pathname = os.path.join(ThumbnailCache.thumbnailDir, f_name) # System call to get current item info f_stat = os.stat(pathname, follow_symlinks=False) # Check if this is a regular file if not stat.S_ISREG(f_stat.st_mode): continue # Compute storage duration since last usage of thumbnail lastUsage = f_stat.st_mtime storageTime = now - lastUsage # logging.debug(f'[ThumbnailCache] Thumbnail {f_name} has been stored for {storageTime}s') if storageTime > ThumbnailCache.storageTimeLimit * 3600 * 24: # Mark as removable if storage time exceeds limit logging.debug(f'[ThumbnailCache] {f_name} exceeded storage time limit') toRemove.append(pathname) else: # Store path and last usage time for potentially sorting and removing later remaining.append((pathname, lastUsage)) # Remove all thumbnails marked as removable for path in toRemove: logging.debug(f'[ThumbnailCache] Remove {path}') try: os.remove(path) except FileNotFoundError: logging.error(f'[ThumbnailCache] Tried to remove {path} but this file does not exist') # Check if number of thumbnails on disk exceeds limit if len(remaining) > ThumbnailCache.maxThumbnailsOnDisk: nbToRemove = len(remaining) - ThumbnailCache.maxThumbnailsOnDisk logging.debug( f'[ThumbnailCache] Too many thumbnails: {len(remaining)} remaining, {nbToRemove} will be removed') # Sort by last usage order and remove excess remaining.sort(key=lambda elt: elt[1]) for i in range(nbToRemove): path = remaining[i][0] logging.debug(f'[ThumbnailCache] Remove {path}') try: os.remove(path) except FileNotFoundError: logging.error(f'[ThumbnailCache] Tried to remove {path} but this file does not exist') @staticmethod def thumbnailPath(imgPath): """Use SHA1 hashing to associate a unique thumbnail to an image. Args: imgPath (str): filepath to the input image Returns: str: filepath to the corresponding thumbnail """ digest = hashlib.sha1(imgPath.encode('utf-8')).hexdigest() path = os.path.join(ThumbnailCache.thumbnailDir, f'{digest}.jpg') return path @staticmethod def removeOutdated(imgPath, path): """Remove thumbnail if its corresponding image has been modified after thumbnail creation. Args: imgPath (str): filepath to the input image path (str): filepath to the corresponding thumbnail """ try: if os.path.getmtime(imgPath) > os.path.getmtime(path): os.remove(path) except OSError: return except FileNotFoundError: return @staticmethod def checkThumbnail(path): """ Check if a thumbnail already exists on disk, and if so update its last modification time. Args: path (str): filepath to the thumbnail Returns: (bool): whether the thumbnail exists on disk or not """ if os.path.exists(path): # Update last modification time Path(path).touch(exist_ok=True) return True return False @Slot(QUrl, int, result=QUrl) def thumbnail(self, imgSource, callerID): """ Retrieve the filepath of the thumbnail corresponding to a given image. If the thumbnail does not exist on disk, it will be created asynchronously. When this is done, the thumbnailCreated signal is emitted. Args: imgSource (QUrl): location of the input image callerID (int): identifier for the object that requested the thumbnail Returns: QUrl: location of the corresponding thumbnail if it exists, otherwise None """ if not imgSource.isValid(): return None if not os.path.exists(ThumbnailCache.thumbnailDir): return imgSource imgPath = imgSource.toLocalFile() path = ThumbnailCache.thumbnailPath(imgPath) # Remove thumbnail in case it is outdated (i.e. if image was modified) ThumbnailCache.removeOutdated(imgPath, path) # Check if thumbnail already exists (and update its last modification time) if ThumbnailCache.checkThumbnail(path): source = QUrl.fromLocalFile(path) return source # Thumbnail does not exist # Create request and submit to worker threads ThumbnailCache.requests.append((imgSource, callerID)) ThumbnailCache.workerThreads.apply_async(func=self.handleRequestsAsync) return None def createThumbnail(self, imgSource, callerID): """ Load an image, resize it to thumbnail dimensions and save the result in the cache directory. Args: imgSource (QUrl): location of the input image callerID (int): identifier for the object that requested the thumbnail """ imgPath = imgSource.toLocalFile() path = ThumbnailCache.thumbnailPath(imgPath) # Check if thumbnail already exists (it may have been created by another thread) if ThumbnailCache.checkThumbnail(path): self.thumbnailCreated.emit(imgSource, callerID) return path logging.debug(f'[ThumbnailCache] Creating thumbnail {path} for image {imgPath}') # Initialize image reader object reader = QImageReader() reader.setFileName(imgPath) reader.setAutoTransform(True) # Read image and check for potential errors img = reader.read() if img.isNull(): logging.error(f'[ThumbnailCache] Error when reading image: {reader.errorString()}') return "" # Scale image while preserving aspect ratio thumbnail = img.scaled(ThumbnailCache.thumbnailSize, aspectMode=Qt.KeepAspectRatio, mode=Qt.SmoothTransformation) # Write thumbnail to disk and check for potential errors writer = QImageWriter(path) success = writer.write(thumbnail) if not success: logging.error(f'[ThumbnailCache] Error when writing thumbnail: {writer.errorString()}') # Notify listeners self.thumbnailCreated.emit(imgSource, callerID) return path def handleRequestsAsync(self): """ Process thumbnail creation requests in LIFO order. Note: this operation waits for the cleaning process to finish before starting, in order to avoid synchronization issues. """ # Wait for cleaning thread to finish if ThumbnailCache.cleaningThread is not None and ThumbnailCache.cleaningThread.is_alive(): ThumbnailCache.cleaningThread.join() # Handle requests until the requests stack is empty try: while True: req = ThumbnailCache.requests.pop() self.createThumbnail(req[0], req[1]) except IndexError: # No more request to process return @Slot() def clearRequests(self): """ Clear all pending thumbnail creation requests. Requests already under treatment by a worker thread will still be completed. """ ThumbnailCache.requests.clear() ================================================ FILE: meshroom/ui/graph.py ================================================ #!/usr/bin/env python from collections.abc import Iterable import logging import os import re import json from enum import Enum from threading import Thread, Event, Lock from multiprocessing.pool import ThreadPool from typing import Optional, Union from collections.abc import Iterator from collections import OrderedDict from PySide6.QtCore import ( Slot, QJsonValue, QObject, QUrl, Property, Signal, QPoint, QItemSelectionModel, QItemSelection, ) from meshroom.core import sessionUid from meshroom.common.qt import QObjectListModel from meshroom.core.attribute import Attribute, ListAttribute, ShapeAttribute from meshroom.core.graph import Graph, Edge, generateTempProjectFilepath from meshroom.core.graphIO import GraphIO from meshroom.core.taskManager import TaskManager from meshroom.core.submitter import jobManager from meshroom.core.node import NodeChunk, Node, Status, ExecMode, CompatibilityNode, BackdropNode, Position from meshroom.core import submitters, MrNodeType from meshroom.ui import commands from meshroom.ui.utils import makeProperty class PollerRefreshStatus(Enum): AUTO_ENABLED = 0 # The file watcher polls every single status file periodically DISABLED = 1 # The file watcher is disabled and never polls any file MINIMAL_ENABLED = 2 # The file watcher only polls status files for chunks that are either submitted or running externally class FilesModTimePollerThread(QObject): """ Thread responsible for non-blocking polling of last modification times of a list of files. Uses a Python ThreadPool internally to split tasks on multiple threads. """ timesAvailable = Signal(list) def __init__(self, parent=None): super().__init__(parent) self._thread = None self._mutex = Lock() self._threadPool = ThreadPool(4) self._stopFlag = Event() self._refreshInterval = 5 # refresh interval in seconds self._files = [] if submitters: self._filePollerRefresh = PollerRefreshStatus.MINIMAL_ENABLED else: self._filePollerRefresh = PollerRefreshStatus.DISABLED def __del__(self): self._threadPool.terminate() self._threadPool.join() def start(self, files=None): """ Start polling thread. Args: files: the list of files to monitor """ if self._filePollerRefresh is PollerRefreshStatus.DISABLED: return if self._thread: # thread already running, return return self._stopFlag.clear() self._files = files or [] self._thread = Thread(target=self.run) self._thread.start() def setFiles(self, files): """ Set the list of files to monitor. Args: files: the list of files to monitor """ logging.debug(f"FilesModTimePollerThread: Watch files {files}") with self._mutex: self._files = files def stop(self): """ Request polling thread to stop. """ if not self._thread: return self._stopFlag.set() self._thread.join() self._thread = None @staticmethod def getFileLastModTime(f): """ Return 'mtime' of the file if it exists, -1 otherwise. """ try: return os.path.getmtime(f) except OSError: return -1 def run(self): """ Poll watched files for last modification time. """ while not self._stopFlag.wait(self._refreshInterval): with self._mutex: files = list(self._files) times = self._threadPool.map(FilesModTimePollerThread.getFileLastModTime, files) with self._mutex: if files == self._files: self.timesAvailable.emit(times) def onFilePollerRefreshChanged(self, value): """ Stop or start the file poller depending on the new refresh status. """ self._filePollerRefresh = PollerRefreshStatus(value) if self._filePollerRefresh is PollerRefreshStatus.DISABLED: self.stop() else: self.start() self.filePollerRefreshReady.emit() filePollerRefresh = Property(int, lambda self: self._filePollerRefresh.value, constant=True) filePollerRefreshReady = Signal() # The refresh status has been updated and is ready to be used class NodeStatusMonitor(QObject): """ NodeStatusMonitor regularly check status files for modification and trigger their update on change. When working locally, status changes are reflected through the emission of 'statusChanged' signals. But when a graph is being computed externally - either via a Submitter or on another machine, Status files are modified by another instance, potentially outside this machine file system scope. Same goes when status files are deleted/modified manually. Thus, for genericity, monitoring is based on regular polling and not file system watching. """ def __init__(self, parent=None): super().__init__(parent) self.monitorableNodes = [] self.monitoredFiles = {} # Dict {filepath: node} self._filesTimePoller = FilesModTimePollerThread(parent=self) self._filesTimePoller.timesAvailable.connect(self.compareFilesTimes) self._filesTimePoller.start() self.setMonitored([]) self.filePollerRefreshChanged.connect(self._filesTimePoller.onFilePollerRefreshChanged) self._filesTimePoller.filePollerRefreshReady.connect(self.onFilePollerRefreshUpdated) def setWatchedFiles(self): self.monitoredItems = self.getMonitoredFiles() monitoredFiles = list([f for f in self.monitoredItems.keys()]) self._filesTimePoller.setFiles(monitoredFiles) def setMonitored(self, nodes): self.monitorableNodes = nodes self.setWatchedFiles() def stop(self): """ Stop the status files monitoring. """ self._filesTimePoller.stop() def getMonitoredFiles(self): monitoredItems = OrderedDict() for node in self.monitorableNodes: if node._chunksCreated: fileItems = {c.getStatusFile(): ("chunk", c) for c in node._chunks} else: fileItems = {node.nodeStatusFile: ("node", node)} if self.filePollerRefresh is PollerRefreshStatus.AUTO_ENABLED.value: # Add everything monitoredItems.update(fileItems) elif self.filePollerRefresh is PollerRefreshStatus.MINIMAL_ENABLED.value: # Only chunks that are run externally or local_isolated should be monitored, # when run locally, status changes are already notified. # Chunks with an ERROR status may be re-submitted externally and should thus still be monitored for file, (_type, _item) in fileItems.items(): if not _item.shouldMonitorChanges(): continue monitoredItems[file] = (_type, _item) return monitoredItems def compareFilesTimes(self, times): """ Compare previous file modification times with results from last poll. Trigger chunk status update if file was modified since. Args: times: the last modification times for currently monitored files. """ newRecords = dict(zip(self.monitoredItems.items(), times)) nodesToUpdate = set() for monitoredItem, fileModTime in newRecords.items(): _, (_type, _item) = monitoredItem if _type == "chunk": chunk = _item # update chunk status if last modification time has changed since previous record if fileModTime != chunk.statusFileLastModTime: chunk.updateStatusFromCache() if chunk._status.status == Status.SUCCESS: nodesToUpdate.add(chunk.node) elif _type == "node": node = _item if fileModTime != node.nodeStatusFileLastModTime: node.updateStatusFromCache() # Check for success if node.getGlobalStatus() == Status.SUCCESS: nodesToUpdate.add(node) elif node._chunksCreated: # Chunks have been created -> set the watched files again self.setWatchedFiles() for node in nodesToUpdate: node.loadOutputAttr() def onFilePollerRefreshUpdated(self): """ Upon an update of the file poller status, retrigger the generation of the list of status files for the chunks that are to be watched. In auto-refresh mode, this includes all the chunks' status files. In minimal auto-refresh mode, this includes only the chunks that are submitted or running. """ if self.filePollerRefresh is not PollerRefreshStatus.DISABLED.value: self.setWatchedFiles() def onComputeStatusChanged(self): """ When a chunk's status is updated, update the list of watched files with submitted and running chunks if the file poller status is minimal auto-refresh. """ if self.filePollerRefresh is PollerRefreshStatus.MINIMAL_ENABLED.value: self.setWatchedFiles() filePollerRefreshChanged = Signal(int) filePollerRefresh = Property(int, lambda self: self._filesTimePoller.filePollerRefresh, notify=filePollerRefreshChanged) class GraphLayout(QObject): """ GraphLayout provides auto-layout features to a UIGraph. """ class DepthMode(Enum): """ Defines available node depth mode to layout the graph automatically. """ MinDepth = 0 # use node minimal depth MaxDepth = 1 # use node maximal depth # map between DepthMode and corresponding node depth attribute name _depthAttribute = { DepthMode.MinDepth: 'minDepth', DepthMode.MaxDepth: 'depth' } def __init__(self, graph): super().__init__(graph) self.graph = graph self._depthMode = GraphLayout.DepthMode.MaxDepth self._nodeWidth = 160 # implicit node width self._nodeHeight = 120 # implicit node height self._gridSpacing = 40 # column/line spacing between nodes @Slot(Node, Node, int, int) def autoLayout(self, fromNode=None, toNode=None, startX=0, startY=0): """ Perform auto-layout from 'fromNode' to 'toNode', starting from (startX, startY) position. Args: fromNode (BaseNode): where to start the auto layout from toNode (BaseNode): up to where to perform the layout startX (int): start position x coordinate startY (int): start position y coordinate """ if not self.graph.nodes: return fromIndex = self.graph.nodes.indexOf(fromNode) if fromNode else 0 toIndex = self.graph.nodes.indexOf(toNode) if toNode else self.graph.nodes.count - 1 def getDepth(n): return getattr(n, self._depthAttribute[self._depthMode]) maxDepth = max([getDepth(n) for n in self.graph.nodes.values()]) grid = [[] for _ in range(maxDepth + 1)] # Retrieve reference depth from start node zeroDepth = getDepth(self.graph.nodes.at(fromIndex)) if fromIndex > 0 else 0 for i in range(fromIndex, toIndex + 1): n = self.graph.nodes.at(i) grid[getDepth(n) - zeroDepth].append(n) with self.graph.groupedGraphModification("Graph Auto-Layout"): for x, line in enumerate(grid): for y, node in enumerate(line): px = startX + x * (self._nodeWidth + self._gridSpacing) py = startY + y * (self._nodeHeight + self._gridSpacing) self.graph.moveNode(node, Position(px, py)) @Slot() def reset(self): """ Perform auto-layout on the whole graph. """ self.autoLayout() def positionBoundingBox(self, nodes=None): """ Return bounding box for a set of nodes as (x, y, width, height). Args: nodes (list of Node): the list of nodes or the whole graph if None Returns: list of int: the resulting bounding box (x, y, width, height) """ if nodes is None: nodes = self.graph.nodes.values() if not nodes: return [0, 0, 0, 0] first = nodes[0] bbox = [first.x, first.y, first.x, first.y] for n in nodes: bbox[0] = min(bbox[0], n.x) bbox[1] = min(bbox[1], n.y) bbox[2] = max(bbox[2], n.x) bbox[3] = max(bbox[3], n.y) bbox[2] -= bbox[0] bbox[3] -= bbox[1] return bbox def boundingBox(self, nodes=None): """ Return bounding box for a set of nodes as (x, y, width, height). Args: nodes (list of Node): the list of nodes or the whole graph if None Returns: list of int: the resulting bounding box (x, y, width, height) """ bbox = self.positionBoundingBox(nodes) bbox[2] += self._nodeWidth bbox[3] += self._nodeHeight return bbox def setDepthMode(self, mode): """ Set node depth mode to use. """ if isinstance(mode, int): mode = GraphLayout.DepthMode(mode) if self._depthMode.value == mode.value: return self._depthMode = mode depthModeChanged = Signal() depthMode = Property(int, lambda self: self._depthMode.value, setDepthMode, notify=depthModeChanged) nodeHeightChanged = Signal() nodeHeight = makeProperty(int, "_nodeHeight", notify=nodeHeightChanged) nodeWidthChanged = Signal() nodeWidth = makeProperty(int, "_nodeWidth", notify=nodeWidthChanged) gridSpacingChanged = Signal() gridSpacing = makeProperty(int, "_gridSpacing", notify=gridSpacingChanged) class UIGraph(QObject): """ High level wrapper over core.Graph, with additional features dedicated to UI integration. UIGraph exposes undoable methods on its graph and computation in a separate thread. It also provides a monitoring of all its computation units (NodeChunks). """ def __init__(self, undoStack: commands.UndoStack, taskManager: TaskManager, parent: QObject = None): super().__init__(parent) self._undoStack = undoStack self._taskManager = taskManager self._graph: Graph = Graph('', self) self._modificationCount = 0 self._chunksMonitor: NodeStatusMonitor = NodeStatusMonitor(parent=self) self._computeThread: Thread = Thread() self._computingLocally = self._submitted = False self._sortedDFSChunks: QObjectListModel = QObjectListModel(parent=self) self._layout: GraphLayout = GraphLayout(self) self._selectedNode = None self._selectedChunk = None self._nodeSelection: QItemSelectionModel = QItemSelectionModel(self._graph.nodes, parent=self) self._hoveredNode = None self.submitLabel = "{projectName}" self.computeStatusChanged.connect(self.updateLockedUndoStack) self.filePollerRefreshChanged.connect(self._chunksMonitor.filePollerRefreshChanged) def setGraph(self, g): """ Set the internal graph. """ if self._graph: self.stopExecution() # Clear all the locally submitted nodes at once before the graph gets changed, as it will not receive further updates if self._computingLocally: self._graph.clearLocallySubmittedNodes() self.clear() oldGraph = self._graph self._graph = g if oldGraph: oldGraph.deleteLater() self._graph.updated.connect(self.onGraphUpdated) self._graph.statusUpdated.connect(self.updateChunkMonitor) self._taskManager.update(self._graph) # Update and connect chunks when the graph is set for the first time self.updateChunks() # Perform auto-layout if graph does not provide nodes positions if GraphIO.Features.NodesPositions not in self._graph.fileFeatures: self._layout.reset() # Clear undo-stack after layout self._undoStack.clear() else: bbox = self._layout.positionBoundingBox() if bbox[2] == 0 and bbox[3] == 0: self._layout.reset() # Clear undo-stack after layout self._undoStack.clear() self._nodeSelection.setModel(self._graph.nodes) self.graphChanged.emit() def onGraphUpdated(self): """ Callback to any kind of attribute modification. """ # TODO: handle this with a better granularity self.updateChunks() def updateChunks(self): dfsNodes = self._graph.dfsOnFinish(None)[0] chunks = [] for node in dfsNodes: if node._chunksCreated: nodechunks = node.getChunks() chunks.extend(nodechunks) else: chunks.extend(node.chunkPlaceholder) if self._sortedDFSChunks.objectList() == chunks: # Nothing has changed, return return for chunk in self._sortedDFSChunks: if chunk not in chunks: # Chunk have been already deleted continue chunk.statusChanged.disconnect(self.updateGraphComputingStatus) chunk.statusChanged.disconnect(self._chunksMonitor.onComputeStatusChanged) self._sortedDFSChunks.setObjectList(chunks) for chunk in self._sortedDFSChunks: chunk.statusChanged.connect(self.updateGraphComputingStatus) chunk.statusChanged.connect(self._chunksMonitor.onComputeStatusChanged) # provide ChunkMonitor with the update list of chunks self.updateChunkMonitor() # update graph computing status based on the new list of NodeChunks self.updateGraphComputingStatus() def updateChunkMonitor(self): """ Update the list of chunks for status files monitoring. """ nodes = set() for node in self._graph.dfsOnFinish(None)[0]: if not node._chunksCreated: nodes.add(node) for chunk in self._sortedDFSChunks: nodes.add(chunk.node) self._chunksMonitor.setMonitored(list(nodes)) def clear(self): if self._graph: self.clearNodeHover() self.clearNodeSelection() self._taskManager.clear() self._graph.clear() self._sortedDFSChunks.clear() self._undoStack.clear() def stopChildThreads(self): """ Stop all child threads. """ self.stopExecution() self._chunksMonitor.stop() @Slot(str) def loadGraph(self, filepath): g = Graph("") if filepath: g.load(filepath) if not os.path.exists(g.cacheDir): os.mkdir(g.cacheDir) self.setGraph(g) @Slot(str) @Slot(str, bool) def initFromTemplate(self, filepath, copyOutputs=False): graph = Graph("") if filepath: graph.initFromTemplate(filepath, copyOutputs=copyOutputs) self.setGraph(graph) @Slot(QUrl, result="QVariantList") @Slot(QUrl, QPoint, result="QVariantList") def importProject(self, filepath, position=None): if isinstance(filepath, (QUrl)): # depending how the QUrl has been initialized, # toLocalFile() may return the local path or an empty string localFile = filepath.toLocalFile() if not localFile: localFile = filepath.toString() else: localFile = filepath if isinstance(position, QPoint): position = Position(position.x(), position.y()) yOffset = self.layout.gridSpacing + self.layout.nodeHeight return self.push(commands.ImportProjectCommand(self._graph, localFile, position=position, yOffset=yOffset)) @Slot(QUrl) def saveAs(self, url): self._saveAs(url) @Slot(QUrl) def saveAsTemplate(self, url): self._saveAs(url, setupProjectFile=False, template=True) def _saveAs(self, url, setupProjectFile=True, template=False): """ 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" self._graph.save(localFile, setupProjectFile=setupProjectFile, template=template) self._undoStack.setClean() # saving file on disk impacts cache folder location # => force re-evaluation of monitored status files paths self.updateChunkMonitor() @Slot() def saveAsTemp(self): projectPath = generateTempProjectFilepath() self._saveAs(projectPath) @Slot() def save(self): self._graph.save() self._undoStack.setClean() @Slot() def saveAsNewVersion(self): self._graph.saveAsNewVersion() self._undoStack.setClean() @Slot() def updateLockedUndoStack(self): if self.isComputingLocally(): self._undoStack.lockAtThisIndex() else: self._undoStack.unlock() @Slot() @Slot(Node) @Slot(list) def execute(self, nodes: Optional[Union[list[Node], Node]] = None): nodes = [nodes] if not isinstance(nodes, Iterable) and nodes else nodes self.save() # always save the graph before computing self._taskManager.compute(self._graph, nodes) self.updateLockedUndoStack() # explicitly call the update while it is already computing @Slot() def stopExecution(self): self.updateChunks() if not self.isComputingLocally(): return self._taskManager.requestBlockRestart() self._graph.stopExecution() self._taskManager.join() @Slot(Node) def stopNodeComputation(self, node): """ Stop the computation of the node and update all the nodes depending on it. """ self.updateChunks() if not self.isComputingLocally(): return # Stop the node and wait Task Manager node.stopComputation() self._taskManager.join() @Slot(Node) def cancelNodeComputation(self, node): """ Cancel the computation of the node and all the nodes depending on it. """ self.updateChunks() if node.getGlobalStatus() == Status.SUBMITTED: # Status from SUBMITTED to NONE # Make sure to remove the nodes from the Task Manager list node.clearSubmittedChunks() self._taskManager.removeNode(node, displayList=True, processList=True) for n in node.getOutputNodes(recursive=True, dependenciesOnly=True): n.clearSubmittedChunks() self._taskManager.removeNode(n, displayList=True, processList=True) def isChunkComputingLocally(self, chunk): # Update graph computing status computingLocally = chunk._status.execMode == ExecMode.LOCAL and \ (sessionUid in (chunk.node._nodeStatus.submitterSessionUid, chunk._status.computeSessionUid)) and \ (chunk._status.status in (Status.RUNNING, Status.SUBMITTED)) return computingLocally def isChunkComputingExternally(self, chunk): # Note: We do not check computeSessionUid for the submitted status, # as the source instance of the submit has no importance. return (chunk._status.execMode == ExecMode.EXTERN) and \ chunk._status.status in (Status.RUNNING, Status.SUBMITTED) @Slot(NodeChunk) def stopTask(self, chunk: NodeChunk): """ Stop the selected task. """ chunk.updateStatusFromCache() if not chunk.isAlreadySubmitted(): return node = chunk.node job = jobManager.getNodeJob(node) if job: chunkIteration = chunk.range.iteration try: job.stopChunkTask(node, chunkIteration) except Exception as e: self.parent().showMessage(f"Failed to stop chunk {chunkIteration} of {node.label}", "error") logging.warning(f"Error on stopTask:\n{e}") else: chunk.updateStatusFromCache() chunk.upgradeStatusTo(Status.STOPPED) # TODO: Stop depending nodes? self.parent().showMessage(f"Stopped chunk {chunkIteration} of {node.label}") else: chunk.stopProcess() self._taskManager._cancelledChunks.append(chunk) for chunk in node._chunks: if chunk._status.status == Status.SUBMITTED: chunk.stopProcess() self._taskManager._cancelledChunks.append(chunk) for n in node.getOutputNodes(recursive=True, dependenciesOnly=True): n.clearSubmittedChunks() self._taskManager.removeNode(n, displayList=True, processList=True) @Slot(Node) def stopNode(self, node: Node): """ Stop the selected task. """ job = jobManager.getNodeJob(node) if job: try: job.stopChunkTask(node, -1) except Exception as e: self.parent().showMessage(f"Failed to stop node {node.label}", "error") logging.warning(f"Error on stopTask:\n{e}") else: node.updateNodeStatusFromCache() node.upgradeStatusTo(Status.STOPPED) # TODO : Stop depending nodes ? self.parent().showMessage(f"Stopped node {node.label}") else: self.cancelNodeComputation(node) node.stopComputation() @Slot(NodeChunk) def restartTask(self, chunk: NodeChunk): """ Relaunch a stopped task. """ node = chunk.node job = jobManager.getNodeJob(node) if job: chunkIteration = chunk.range.iteration try: chunk.updateStatusFromCache() chunk.upgradeStatusTo(Status.SUBMITTED) job.restartChunkTask(node, chunkIteration) except Exception as e: chunk.updateStatusFromCache() chunk.upgradeStatusTo(Status.ERROR) self.parent().showMessage(f"Failed to relaunch chunk {chunkIteration} of {node.label}", "error") logging.warning(f"Error on restartTask:\n{e}") else: self.parent().showMessage(f"Relaunched chunk {chunkIteration} of {node.label}") else: # For this we would need to use a pool (with either chunks or nodes) # instead of the list of nodes that are processed serially self.parent().showMessage(f"Chunks cannot be launched individually locally", "warning") if self.canComputeNode(node): self.execute([node]) @Slot(NodeChunk) def skipTask(self, chunk: NodeChunk): """ Skip the task: the job will continue as if the task succeeded. In local mode, the chunk status will be set to success. """ chunk.updateStatusFromCache() node = chunk.node chunkIteration = chunk.range.iteration job = jobManager.getNodeJob(node) if job: try: job.skipChunkTask(node, chunkIteration) except Exception as e: self.parent().showMessage(f"Failed to skip chunk {chunkIteration} of {node.label}", "error") logging.warning(f"Error on skipTask:\n{e}") else: chunk.upgradeStatusTo(Status.SUCCESS) self.parent().showMessage(f"Skipped chunk {chunkIteration} of {node.label}") else: chunk.stopProcess() chunk.upgradeStatusTo(Status.SUCCESS) self._taskManager._cancelledChunks.append(chunk) self.parent().showMessage(f"Skipped chunk {chunkIteration} of {node.label}") @Slot(Node) def pauseJob(self, node: Node): """ Pause the running job : cancel all scheduled tasks. Current task is not stopped but future tasks will not be launched. """ job = jobManager.getNodeJob(node) if job: try: job.pauseJob() except Exception as e: logging.warning(f"Error on pauseJob:\n{e}") self.parent().showMessage(f"Failed to pause the job for node {node}", "error") else: self.parent().showMessage(f"Paused node {node.label} on farm") elif not node.isExtern(): self.parent().showMessage(f"PauseJob is only available in external computation mode!", "warning") else: self.parent().showMessage(f"Cannot retrieve the job", "error") @Slot(Node) def resumeJob(self, node: Node): """ Resume the paused job. """ job = jobManager.getNodeJob(node) if job: # Node is submitted to farm try: job.resumeJob() except Exception as e: self.parent().showMessage(f"Failed to resume node {node.label} on farm") logging.warning(f"Error on resumeJob:\n{e}") else: self.parent().showMessage(f"Resumed the job for node {node}") else: # In this case user can just relaunch the node computation # Could be implemented if we had a paused state on the task manager # Where unprocessed nodes are retained pass @Slot(Node) def interruptJob(self, node: Node): """ Interrupt the job that processes the node. """ job = jobManager.getNodeJob(node) if job: try: job.interruptJob() except Exception as e: self.parent().showMessage(f"Failed to interrupt node {node.label} on farm", "error") logging.warning(f"Error on interruptJob:\n{e}") else: for chunk in self._sortedDFSChunks: if jobManager.getNodeJob(chunk.node) == job: if chunk._status.status in (Status.SUBMITTED, Status.RUNNING): chunk.updateStatusFromCache() chunk.upgradeStatusTo(Status.STOPPED) for _node in self._graph.dfsOnFinish(None)[0]: if jobManager.getNodeJob(_node) == job and not _node._chunksCreated and \ _node._nodeStatus.status in (Status.SUBMITTED, Status.RUNNING): _node.upgradeStatusTo(Status.STOPPED) self.parent().showMessage(f"Interrupted the job for node {node}") elif not node.isExtern(): for chunk in self._sortedDFSChunks: if not chunk.isExtern() and chunk._status.status in (Status.SUBMITTED, Status.RUNNING): chunk.updateStatusFromCache() chunk.upgradeStatusTo(Status.STOPPED) for node in self._graph.dfsOnFinish(None)[0]: if not node.isExtern() and not node._chunksCreated and \ node._nodeStatus.status in (Status.SUBMITTED, Status.RUNNING): node.upgradeStatusTo(Status.STOPPED) self.stopExecution() self.parent().showMessage(f"Stopped the local job process") else: self.parent().showMessage(f"Could not retrieve job for node {node}", "error") @Slot(Node) def restartJobErrorTasks(self, node: Node): """ Restart all tasks in the job that have failed. """ job = jobManager.getNodeJob(node) if job: try: # Fist update status of each chunk to submitted for chunk in self._sortedDFSChunks: if chunk._status.status not in (Status.ERROR, Status.STOPPED, Status.KILLED): continue if jobManager.getNodeJob(chunk.node) == job: chunk.upgradeStatusTo(Status.SUBMITTED) for node in self._graph.dfsOnFinish(None)[0]: if not node._chunksCreated and node._nodeStatus.status in (Status.ERROR, Status.STOPPED, Status.KILLED): node.upgradeStatusTo(Status.SUBMITTED) job.restartErrorTasks() job.resumeJob() except Exception as e: self.parent().showMessage(f"Failed to restart error tasks for node {node.label} on farm", "error") logging.warning(f"Error on restartJobErrorTasks:\n{e}") else: self.parent().showMessage(f"Restarted error tasks for the node {node}") else: # In this case user can just relaunch the node computation # Could be implemented if we had a paused state on the task manager # Where error/failed nodes are retained pass @Slot() @Slot(Node) @Slot(list) def submit(self, nodes: Optional[Union[list[Node], Node]] = None): """ Submit the graph to the default Submitter. If a node is specified, submit this node and its uncomputed predecessors. Otherwise, submit the whole Notes: Default submitter is specified using the MESHROOM_DEFAULT_SUBMITTER environment variable. """ self.save() # graph must be saved before being submitted self._undoStack.clear() # the undo stack must be cleared nodes = [nodes] if not isinstance(nodes, Iterable) and nodes else nodes mrDefaultSubmitter = os.environ.get('MESHROOM_DEFAULT_SUBMITTER', '') chosenSubmitter = self.parent()._defaultSubmitterName or mrDefaultSubmitter self.parent().showMessage(f"Submit job on farm through {chosenSubmitter}") self.parent().showMessage(f"Nodes to submit : {nodes}") self._taskManager.submit(self._graph, chosenSubmitter, nodes, submitLabel=self.submitLabel) def updateGraphComputingStatus(self): dfsNodes = self._graph.dfsOnFinish(None)[0] # TODO : these functions should go on the node part # We should do any([node.isRunning for node in dfsNodes]) # update graph computing status computingLocally = any([ ch._status.execMode == ExecMode.LOCAL and \ (sessionUid in (ch.node._nodeStatus.submitterSessionUid, ch._status.computeSessionUid)) and \ (ch._status.status in (Status.RUNNING, Status.SUBMITTED)) for ch in self._sortedDFSChunks ]) # Note: We do not check computeSessionUid for the submitted status, # as the source instance of the submit has no importance. submitted = any([ch._status.execMode == ExecMode.EXTERN and ch._status.status in (Status.RUNNING, Status.SUBMITTED) for ch in self._sortedDFSChunks]) # Handle nodes with uninitialized chunks for node in dfsNodes: if node._chunksCreated: continue if node._nodeStatus.status in (Status.RUNNING, Status.SUBMITTED): # TODO : save session ID in node if node._nodeStatus.execMode == ExecMode.LOCAL: computingLocally = True elif node._nodeStatus.execMode == ExecMode.EXTERN: submitted = True if self._computingLocally != computingLocally or self._submitted != submitted: self._computingLocally = computingLocally self._submitted = submitted self.computeStatusChanged.emit() def isComputing(self): """ Whether is graph is being computed, either locally or externally. """ return self.isComputingLocally() or self.isComputingExternally() def isComputingExternally(self): """ Whether this graph is being computed externally. """ return self._submitted def isComputingLocally(self): """ Whether this graph is being computed locally (i.e computation can be stopped). """ ## One solution could be to check if the thread is still running, # but the latency in creating/stopping the thread can be off regarding the update signals. # isRunningThread = self._taskManager._thread.isRunning() ## Another solution is to retrieve the current status directly from all chunks status # isRunning = self._taskManager.hasRunningChunks() ## For performance reason, we use a precomputed value updated in updateGraphComputingStatus: return self._computingLocally def push(self, command): """ Try and push the given command to the undo stack. Args: command (commands.UndoCommand): the command to push """ return self._undoStack.tryAndPush(command) def groupedGraphModification(self, title, disableUpdates=True): """ Get a GroupedGraphModification for this Graph. Args: title (str): the title of the macro command disableUpdates (bool): whether to disable graph updates Returns: GroupedGraphModification: the instantiated context manager """ return commands.GroupedGraphModification(self._graph, self._undoStack, title, disableUpdates) @Slot(str) def beginModification(self, name): """ Begin a Graph modification. Calls to beginModification and endModification may be nested, but every call to beginModification must have a matching call to endModification. """ self._modificationCount += 1 self._undoStack.beginMacro(name) @Slot() def endModification(self): """ Ends a Graph modification. Must match a call to beginModification. """ assert self._modificationCount > 0 self._modificationCount -= 1 self._undoStack.endMacro() @Slot(str, QPoint, result=QObject) def addNewNode(self, nodeType, position=None, **kwargs): """ [Undoable] Create a new Node of type 'nodeType' and returns it. Args: nodeType (str): the type of the Node to create. position (QPoint): (optional) the initial position of the node **kwargs: optional node attributes values Returns: Node: the created node """ if isinstance(position, QPoint): position = Position(position.x(), position.y()) return self.push(commands.AddNodeCommand(self._graph, nodeType, position=position, **kwargs)) @Slot(Node, str, result=str) def renameNode(self, node: Node, newName: str): """ Triggers the node renaming. In this function the last `_N` index is removed, then all special characters (everything except letters and numbers) are removed. The name uniqueness will be ensured later by adding a suffix (e.g. `_1`, `_2`, ...) Labels can be used to have special characters in the displayed name. Args: node (Node): Node to rename. newName (str): New name to set. Returns: str: The final name of the node. """ newName = "_".join(newName.split("_")[:-1]) if "_" in newName else newName # Eliminate all characters except digits and letters newName = re.sub(r"[^0-9a-zA-Z]", "", newName) # Create unique name uniqueName = self._graph._createUniqueNodeName(newName, {n._name for n in self._graph._nodes if n != node}) if not newName or uniqueName == node._name: return "" return self.push(commands.RenameNodeCommand(self._graph, node, uniqueName)) def moveNode(self, node: Node, position: Position): """ Move `node` to the given `position`. Args: node: The node to move. position: The target position. """ self.push(commands.MoveNodeCommand(self._graph, node, position)) @Slot(BackdropNode, int, int) def resizeNode(self, node, width, height): """ Resize `node` to the given `width` and `height`. Args: node: The node to resize. width: The target width. height: The target height. """ with self.groupedGraphModification("Resize Node"): if node.hasInternalAttribute("nodeWidth"): self.setAttribute(node.internalAttribute("nodeWidth"), width) if node.hasInternalAttribute("nodeHeight"): self.setAttribute(node.internalAttribute("nodeHeight"), height) @Slot(BackdropNode, int, int, QPoint) def resizeAndMoveNode(self, node, width, height, position=None): """ Resize `node` to the given `width` and `height`, and move it to the given `position`. Args: node: The node to resize and move. width: The target width. height: The target height. position: The target position. """ with self.groupedGraphModification("Resize and Move Node"): if node.hasInternalAttribute("nodeWidth"): self.setAttribute(node.internalAttribute("nodeWidth"), width) if node.hasInternalAttribute("nodeHeight"): self.setAttribute(node.internalAttribute("nodeHeight"), height) if position: self.moveNode(node, Position(position.x(), position.y())) @Slot(QPoint, int, int, result=QObject) def addBackdropNode(self, position, width, height): """[Undoable] Create a new Backdrop Node at the given position with the given dimensions as a single undo entry. Args: position (QPoint): the position of the backdrop node. width (int): the width of the backdrop node. height (int): the height of the backdrop node. Returns: BackdropNode: the created node. """ with self.groupedGraphModification("Add Backdrop"): node = self.addNewNode("Backdrop", position) if node.hasInternalAttribute("nodeWidth"): self.setAttribute(node.internalAttribute("nodeWidth"), width) if node.hasInternalAttribute("nodeHeight"): self.setAttribute(node.internalAttribute("nodeHeight"), height) return node @Slot(QPoint) def moveSelectedNodesBy(self, offset: QPoint): """ Move all the selected nodes by the given `offset`. """ with self.groupedGraphModification("Move Selected Nodes"): for node in self.iterSelectedNodes(): position = Position(node.x + offset.x(), node.y + offset.y()) self.moveNode(node, position) def getMeanPosition(self): """ Get the average Position of selected nodes. """ # Not great, would be better if Position was a non-tuple class selectedNodes = self.getSelectedNodes() sum_pose = [0, 0] nb_tot = 0 for selectedNode in selectedNodes: sum_pose[0] += selectedNode.x sum_pose[1] += selectedNode.y nb_tot += 1 return Position(int(sum_pose[0] / nb_tot), int(sum_pose[1] / nb_tot)) @Slot() def alignHorizontally(self): """ All nodes are moved horizontally to the same position, on an average position of selected nodes. """ nodePadding = 50 selectedNodes = self.getSelectedNodes() if len(selectedNodes) < 2: return # Make sure the list is correctly ordered selectedNodes = sorted(selectedNodes, key=lambda node:node.x) meanX, meanY = self.getMeanPosition() nodeWidth = self.layout.nodeWidth # Compute the first node X position totalWidth = len(selectedNodes) * nodeWidth + (len(selectedNodes) - 1) * nodePadding startX = int(meanX - totalWidth / 2 + nodeWidth / 2) with self.groupedGraphModification("Align nodes horizontally"): for i, selectedNode in enumerate(selectedNodes): x = startX + i * (nodeWidth + nodePadding) self.moveNode(selectedNode, Position(x, meanY)) @Slot() def alignVertically(self): """ All nodes are moved vertically to the same position, on an average position of selected nodes. """ selectedNodes = self.getSelectedNodes() if len(selectedNodes) < 2: return meanX, _ = self.getMeanPosition() with self.groupedGraphModification("Align nodes vertically"): for selectedNode in selectedNodes: self.moveNode(selectedNode, Position(meanX, selectedNode.y)) @Slot(list) def removeNodes(self, nodes: list[Node]): """ Remove 'nodes' from the graph. Args: nodes: The nodes to remove. """ if any(n.locked for n in nodes): return with self.groupedGraphModification("Remove Nodes"): for node in nodes: self.push(commands.RemoveNodeCommand(self._graph, node)) @Slot() def removeSelectedNodes(self): """ Remove selected nodes from the graph. """ self.removeNodes(list(self.iterSelectedNodes())) @Slot(list) def removeNodesFrom(self, nodes: list[Node]): """ Remove all nodes starting from 'nodes' to graph leaves. Args: nodes: the nodes to start from. """ with self.groupedGraphModification("Remove Nodes From Selected Nodes"): nodesToRemove, _ = self._graph.dfsOnDiscover(startNodes=nodes, reverse=True, dependenciesOnly=True) # Filter out nodes that will be removed more than once uniqueNodesToRemove = list(dict.fromkeys(nodesToRemove)) # Perform nodes removal from leaves to start node so that edges can be re-created in correct order on redo self.removeNodes(list(reversed(uniqueNodesToRemove))) @Slot(list, result=list) def duplicateNodes(self, nodes: list[Node]) -> list[Node]: """ Duplicate 'nodes'. Args: nodes: the nodes to duplicate. Returns: The list of duplicated nodes. """ nPositions = [(n.x, n.y) for n in self._graph.nodes] # Enable updates between duplication and layout to get correct depths during layout with self.groupedGraphModification("Duplicate Selected Nodes", disableUpdates=False): # Disable graph updates during duplication with self.groupedGraphModification("Node duplication", disableUpdates=True): duplicates = self.push(commands.DuplicateNodesCommand(self._graph, nodes)) # Move nodes below the bounding box formed by the duplicated node(s) bbox = self._layout.boundingBox(nodes) for n in duplicates: yPos = n.y + self.layout.gridSpacing + bbox[3] if (n.x, yPos) in nPositions: # Make sure the node will not be moved on top of another node while (n.x, yPos) in nPositions: yPos = yPos + self.layout.gridSpacing + self.layout.nodeHeight self.moveNode(n, Position(n.x, yPos)) else: self.moveNode(n, Position(n.x, bbox[3] + self.layout.gridSpacing + n.y)) nPositions.append((n.x, n.y)) return duplicates @Slot(list, result=list) def duplicateNodesFrom(self, nodes: list[Node]) -> list[Node]: """ Duplicate all nodes starting from 'nodes' to graph leaves. Args: node: The nodes to start from. Returns: The list of duplicated nodes. """ with self.groupedGraphModification("Duplicate Nodes From Selected Nodes"): nodesToDuplicate, _ = self._graph.dfsOnDiscover(startNodes=nodes, reverse=True, dependenciesOnly=True) # Filter out nodes that will be duplicated more than once uniqueNodesToDuplicate = list(dict.fromkeys(nodesToDuplicate)) duplicates = self.duplicateNodes(uniqueNodesToDuplicate) return duplicates @Slot(Edge, result=bool) def canExpandForLoop(self, currentEdge): """ Check if the list attribute can be expanded by looking at all the edges connected to it. """ listAttribute = currentEdge.src.root # Check that the parent is indeed a ListAttribute (it could be a GroupAttribute, for example) if not listAttribute or not isinstance(listAttribute, ListAttribute): return False srcIndex = listAttribute.index(currentEdge.src) allSrc = [e.src for e in self._graph.edges.values()] for i in range(len(listAttribute)): if i == srcIndex: continue if listAttribute.at(i) in allSrc: return False return True @Slot(Edge, result=Edge) def expandForLoop(self, currentEdge): """ Expand 'node' by creating all its output nodes. """ with self.groupedGraphModification("Expand For Loop Node"): listAttribute = currentEdge.src.root dst = currentEdge.dst for i in range(1, len(listAttribute)): duplicates = self.duplicateNodesFrom([dst.node]) newNode = duplicates[0] previousEdge = self.graph.edge(newNode.attribute(dst.name)) self.replaceEdge(previousEdge, listAttribute.at(i), previousEdge.dst) # Last, replace the edge with the first element of the list return self.replaceEdge(currentEdge, listAttribute.at(0), dst) @Slot(Edge) def collapseForLoop(self, currentEdge): """ Collapse 'node' by removing all its output nodes. """ with self.groupedGraphModification("Collapse For Loop Node"): listAttribute = currentEdge.src.root srcIndex = listAttribute.index(currentEdge.src) allSrc = [e.src for e in self._graph.edges.values()] for i in reversed(range(len(listAttribute))): if i == srcIndex: continue occurrence = allSrc.index(listAttribute.at(i)) if listAttribute.at(i) in allSrc else -1 if occurrence != -1: self.removeNodesFrom([self.graph.edges.at(occurrence).dst.node]) # update the edges from allSrc allSrc = [e.src for e in self._graph.edges.values()] @Slot() def clearSelectedNodesData(self): """ Clear data from all selected nodes. """ self.clearData(self.iterSelectedNodes()) @Slot(list) def clearData(self, nodes: list[Node]): """ Clear data from 'nodes'. """ for n in nodes: n.clearData() @Slot(list) def clearDataFrom(self, nodes: list[Node]): """ Clear data from all nodes starting from 'nodes' to graph leaves. Args: nodes: The nodes to start from. """ self.clearData(self._graph.dfsOnDiscover(startNodes=nodes, reverse=True, dependenciesOnly=True)[0]) @Slot(Attribute, Attribute) def addEdge(self, src, dst): if isinstance(src, ListAttribute) and not isinstance(dst, ListAttribute): self._addEdge(src.at(0), dst) elif isinstance(dst, ListAttribute) and not isinstance(src, ListAttribute): with self.groupedGraphModification(f"Insert and Add Edge on {dst.fullName}"): self.appendAttribute(dst) self._addEdge(src, dst.at(-1)) else: self._addEdge(src, dst) def _addEdge(self, src, dst): with self.groupedGraphModification(f"Connect '{src.fullName}'->'{dst.fullName}'"): if dst in self._graph.edges.keys(): self.removeEdge(self._graph.edge(dst)) self.push(commands.AddEdgeCommand(self._graph, src, dst)) @Slot(Edge) def removeEdge(self, edge): with self.groupedGraphModification(f"Remove Edge and Delete {edge.dst.fullName}"): if isinstance(edge.dst.root, ListAttribute): self.push(commands.RemoveEdgeCommand(self._graph, edge)) self.removeAttribute(edge.dst) return self.push(commands.RemoveEdgeCommand(self._graph, edge)) @Slot(list) def deleteEdgesByIndices(self, indices): with self.groupedGraphModification("Remove Edges"): copied = list(self._graph.edges) for index in indices: self.removeEdge(copied[index]) @Slot() def disconnectSelectedNodes(self): with self.groupedGraphModification("Disconnect Nodes"): selectedNodes = self.getSelectedNodes() for edge in self._graph.edges[:]: # Remove only the edges which are coming or going out of the current selection if edge.src.node in selectedNodes and edge.dst.node in selectedNodes: continue if edge.dst.node in selectedNodes or edge.src.node in selectedNodes: self.removeEdge(edge) @Slot(Edge, Attribute, Attribute, result=Edge) def replaceEdge(self, edge, newSrc, newDst): with self.groupedGraphModification(f"Replace Edge '{edge.src.fullName}'->'{edge.dst.fullName}' with '{newSrc.fullName}'->'{newDst.fullName}'"): self.removeEdge(edge) self.addEdge(newSrc, newDst) return self._graph.edge(newDst) @Slot(Attribute, result=Edge) def getEdge(self, dst): return self._graph.edge(dst) @Slot(Attribute, "QVariant") def setAttribute(self, attribute, value): self.push(commands.SetAttributeCommand(self._graph, attribute, value)) @Slot(Attribute) def resetAttribute(self, attribute): """ Reset 'attribute' to its default value. """ with self.groupedGraphModification(f"Reset Attribute '{attribute.name}'"): # if the attribute is a ListAttribute, remove all edges if isinstance(attribute, ListAttribute): for edge in self._graph.edges: # if the edge is connected to one of the ListAttribute's elements, remove it if edge.src in attribute.value: self.removeEdge(edge) self.push(commands.SetAttributeCommand(self._graph, attribute, attribute.getDefaultValue())) @Slot(Attribute, str, "QVariant") def addAttributeKeyValue(self, attribute, key, value): """ Add the given (key, value) pair to the given keyable attribute. """ self.push(commands.AddAttributeKeyValueCommand(self._graph, attribute, key, value)) @Slot(Attribute, str) def addAttributeKeyDefaultValue(self, attribute, key): """ Add the given key with the default value to the given keyable attribute. """ self.push(commands.AddAttributeKeyValueCommand(self._graph, attribute, key, attribute.getDefaultValue())) @Slot(Attribute, str) def removeAttributeKey(self, attribute, key): """ Remove the given key from the given keyable attribute. """ self.push(commands.RemoveAttributeKeyCommand(self._graph, attribute, key)) @Slot(str, str, "QVariant") def setObservationFromName(self, shapeFullName, key, observation): """ Set the given observation for the given shape attribute name. """ shape = self.graph.attribute(shapeFullName) if shape is None: shape = self.graph.internalAttribute(shapeFullName) self.push(commands.SetObservationCommand(self._graph, shape, key, observation)) @Slot(ShapeAttribute, str, "QVariant") def setObservation(self, shape, key, observation): """ Set the given observation for the given shape attribute. """ self.push(commands.SetObservationCommand(self._graph, shape, key, observation)) @Slot(ShapeAttribute, str) def removeObservation(self, shape, key): """ Remove the given observation for the given shape attribute. """ self.push(commands.RemoveObservationCommand(self._graph, shape, key)) @Slot(CompatibilityNode, result=Node) def upgradeNode(self, node): """ Upgrade a CompatibilityNode. """ return self.push(commands.UpgradeNodeCommand(self._graph, node)) @Slot() def upgradeAllNodes(self): """ Upgrade all upgradable CompatibilityNode instances in the graph. """ with self.groupedGraphModification("Upgrade all Nodes"): nodes = [n for n in self._graph._compatibilityNodes.values() if n.canUpgrade] sortedNodes = sorted(nodes, key=lambda x: x.name) for node in sortedNodes: self.upgradeNode(node) @Slot() def forceNodesStatusUpdate(self): """ Force re-evaluation of graph's nodes status. """ self._graph.updateStatusFromCache(force=True) @Slot(Attribute, QJsonValue) def appendAttribute(self, attribute, value=QJsonValue()): if isinstance(value, QJsonValue): if value.isArray(): pyValue = value.toArray().toVariantList() else: pyValue = None if value.isNull() else value.toObject() else: pyValue = value self.push(commands.ListAttributeAppendCommand(self._graph, attribute, pyValue)) @Slot(Attribute) def removeAttribute(self, attribute): self.push(commands.ListAttributeRemoveCommand(self._graph, attribute)) @Slot(Attribute) def removeImage(self, image): if image is None: return with self.groupedGraphModification("Remove Image"): # Look if the viewpoint's intrinsic is used by another viewpoint # If not, remove it intrinsicId = image.intrinsicId.value intrinsicUsed = False for intrinsic in self.cameraInit.attribute("viewpoints").getSerializedValue(): if image.getSerializedValue() != intrinsic and intrinsic['intrinsicId'] == intrinsicId: intrinsicUsed = True break if not intrinsicUsed: # Find the intrinsic and remove it for intrinsic in self.cameraInit.attribute("intrinsics"): if intrinsic.getSerializedValue()["intrinsicId"] == intrinsicId: self.removeAttribute(intrinsic) break # After every check we finally remove the attribute self.removeAttribute(image) @Slot() def removeAllImages(self): with self.groupedGraphModification("Remove All Images"): self.push(commands.RemoveImagesCommand(self._graph, [self.cameraInit])) @Slot() def removeImagesFromAllGroups(self): with self.groupedGraphModification("Remove Images From All CameraInit Nodes"): self.push(commands.RemoveImagesCommand(self._graph, list(self.cameraInits))) @Slot(list) @Slot(list, int) def selectNodes(self, nodes, command=QItemSelectionModel.SelectionFlag.ClearAndSelect): """ Update selection with `nodes` using the specified `command`. """ indices = [self._graph._nodes.indexOf(node) for node in nodes] self.selectNodesByIndices(indices, command) @Slot(Node) @Slot(Node, int) def selectFollowing(self, node: Node, command=QItemSelectionModel.SelectionFlag.ClearAndSelect): """ Select all the nodes that depend on `node`. """ self.selectNodes( self._graph.dfsOnDiscover(startNodes=[node], reverse=True, dependenciesOnly=True)[0], command ) self.selectedNode = node @Slot(int) @Slot(int, int) def selectNodeByIndex(self, index: int, command=QItemSelectionModel.SelectionFlag.ClearAndSelect): """ Update selection with node at the given `index` using the specified `command`. """ if isinstance(command, int): command = QItemSelectionModel.SelectionFlag(command) self.selectNodesByIndices([index], command) if self._nodeSelection.isRowSelected(index): self.selectedNode = self._graph.nodes.at(index) @Slot(list) @Slot(list, int) def selectNodesByIndices( self, indices: list[int], command=QItemSelectionModel.SelectionFlag.ClearAndSelect ): """ Update selection with node at given `indices` using the specified `command`. Args: indices: The list of indices to select. command: The selection command to use. """ if isinstance(command, int): command = QItemSelectionModel.SelectionFlag(command) itemSelection = QItemSelection() for index in indices: itemSelection.select( self._graph.nodes.index(index), self._graph.nodes.index(index) ) self._nodeSelection.select(itemSelection, command) if self.selectedNode and not self.isSelected(self.selectedNode): self.selectedNode = None def iterSelectedNodes(self) -> Iterator[Node]: """ Iterate over the currently selected nodes. """ for idx in self._nodeSelection.selectedRows(): yield self._graph.nodes.at(idx.row()) @Slot(result=list) def getSelectedNodes(self) -> list[Node]: """ Return the list of selected Node instances. """ return list(self.iterSelectedNodes()) @Slot(Node, result=bool) def isSelected(self, node: Node) -> bool: """ Whether `node` is part of the current selection. """ return self._nodeSelection.isRowSelected(self._graph.nodes.indexOf(node)) @Slot() def clearNodeSelection(self): """ Clear all node selection. """ self.selectedNode = None self._nodeSelection.clear() def clearNodeHover(self): """ Reset currently hovered node to None. """ self.hoveredNode = None @Slot(str) def setSelectedNodesColor(self, color: str): """ Sets the Provided color on the selected Nodes. Args: color (str): Hex code of the color to be set on the nodes. """ # Update the color attribute of the nodes which are currently selected with self.groupedGraphModification("Set Nodes Color"): # For each of the selected nodes -> Check if the node has a color -> Apply the color if it has for node in self.iterSelectedNodes(): if node.hasInternalAttribute("color"): self.setAttribute(node.internalAttribute("color"), color) @Slot(result=str) def getSelectedNodesContent(self) -> str: """ Serialize the current node selection and return it as JSON formatted string. Returns an empty string if the selection is empty. """ if not self._nodeSelection.hasSelection(): return "" graphData = self._graph.serializePartial(self.getSelectedNodes()) return json.dumps(graphData, indent=4) @Slot(str, QPoint, result=list) def pasteNodes(self, serializedData: str, position: Optional[QPoint]=None) -> list[Node]: """ Import string-serialized graph content `serializedData` in the current graph, optionally at the given `position`. If the `serializedData` does not contain valid serialized graph data, nothing is done. This method can be used with the result of "getSelectedNodesContent". But it also accepts any serialized content that matches the graph data or graph content format. For example, it is enough to have: {"nodeName_1": {"nodeType":"CameraInit"}, "nodeName_2": {"nodeType":"FeatureMatching"}} in `serializedData` to create a default CameraInit and a default FeatureMatching nodes. Args: serializedData: The string-serialized graph data. position: The position where to paste the nodes. If None, the nodes are pasted at (0, 0). Returns: list: the list of Node objects that were pasted and added to the graph """ try: graphData = json.loads(serializedData) except json.JSONDecodeError: logging.warning("Content is not a valid JSON string.") return [] pos = Position(position.x(), position.y()) if position else Position(0, 0) result = self.push(commands.PasteNodesCommand(self._graph, graphData, pos)) if result is False: logging.warning("Content is not a valid graph data.") return [] return result @Slot(Node, result=bool) def canComputeNode(self, node: Node) -> bool: """ Check if the node can be computed. """ if node.isCompatibilityNode or not node.isComputableType or node.getLocked(): return False if node.isComputed: return True if self._graph.canComputeTopologically(node) and self._graph.canSubmitOrCompute(node) % 2 == 1: return True return False @Slot(Node, result=bool) def canSubmitNode(self, node: Node) -> bool: """ Check if the node can be submitted. """ if node.isCompatibilityNode or not node.isComputableType or node.getLocked(): return False if node.isComputed: return True if self._graph.canComputeTopologically(node) and self._graph.canSubmitOrCompute(node)> 1: return True return False undoStack = Property(QObject, lambda self: self._undoStack, constant=True) graphChanged = Signal() graph = Property(Graph, lambda self: self._graph, notify=graphChanged) taskManager = Property(TaskManager, lambda self: self._taskManager, constant=True) nodes = Property(QObject, lambda self: self._graph.nodes, notify=graphChanged) layout = Property(GraphLayout, lambda self: self._layout, constant=True) computeStatusChanged = Signal() computing = Property(bool, isComputing, notify=computeStatusChanged) computingExternally = Property(bool, isComputingExternally, notify=computeStatusChanged) computingLocally = Property(bool, isComputingLocally, notify=computeStatusChanged) canSubmit = Property(bool, lambda self: len(submitters), constant=True) sortedDFSChunks = Property(QObject, lambda self: self._sortedDFSChunks, constant=True) lockedChanged = Signal() selectedNodeChanged = Signal() # Current main selected node selectedNode = makeProperty(QObject, "_selectedNode", selectedNodeChanged, resetOnDestroy=True) # Current chunk selected (used to send signals from TaskManager to ChunksListView) selectedChunkChanged = Signal() selectedChunk = makeProperty(QObject, "_selectedChunk", selectedChunkChanged, resetOnDestroy=True) nodeSelection = makeProperty(QObject, "_nodeSelection") hoveredNodeChanged = Signal() # Currently hovered node hoveredNode = makeProperty(QObject, "_hoveredNode", hoveredNodeChanged, resetOnDestroy=True) filePollerRefreshChanged = Signal(int) filePollerRefresh = Property(int, lambda self: self._chunksMonitor.filePollerRefresh, notify=filePollerRefreshChanged) ================================================ FILE: meshroom/ui/palette.py ================================================ from PySide6.QtCore import QObject, Qt, Slot, Property, Signal from PySide6.QtGui import QPalette, QColor from PySide6.QtWidgets import QApplication class PaletteManager(QObject): """ Manages QApplication's palette and provides a toggle between a dark and a light theme. """ def __init__(self, qmlEngine, parent=None): super().__init__(parent) self.qmlEngine = qmlEngine darkPalette = QPalette() window = QColor(50, 52, 55) text = QColor(200, 200, 200) disabledText = text.darker(170) base = window.darker(150) button = window.lighter(115) highlight = QColor(42, 130, 218) dark = window.darker(170) darkPalette.setColor(QPalette.Window, window) darkPalette.setColor(QPalette.WindowText, text) darkPalette.setColor(QPalette.Disabled, QPalette.WindowText, disabledText) darkPalette.setColor(QPalette.Base, base) darkPalette.setColor(QPalette.AlternateBase, QColor(46, 47, 48)) darkPalette.setColor(QPalette.ToolTipBase, base) darkPalette.setColor(QPalette.ToolTipText, text) darkPalette.setColor(QPalette.Text, text) darkPalette.setColor(QPalette.Disabled, QPalette.Text, disabledText) darkPalette.setColor(QPalette.Button, button) darkPalette.setColor(QPalette.ButtonText, text) darkPalette.setColor(QPalette.Disabled, QPalette.ButtonText, disabledText) darkPalette.setColor(QPalette.Mid, button.lighter(120)) darkPalette.setColor(QPalette.Highlight, highlight) darkPalette.setColor(QPalette.Disabled, QPalette.Highlight, QColor(80, 80, 80)) darkPalette.setColor(QPalette.HighlightedText, Qt.white) darkPalette.setColor(QPalette.Disabled, QPalette.HighlightedText, QColor(127, 127, 127)) darkPalette.setColor(QPalette.Shadow, Qt.black) darkPalette.setColor(QPalette.Link, highlight.lighter(130)) self.darkPalette = darkPalette self.defaultPalette = QApplication.instance().palette() self.defaultPalette.setColor(QPalette.Text, QColor(50, 50, 50)) self.defaultPalette.setColor(QPalette.HighlightedText, Qt.black) self.togglePalette() @Slot() def togglePalette(self): app = QApplication.instance() if app.palette() == self.darkPalette: app.setPalette(self.defaultPalette) else: app.setPalette(self.darkPalette) if self.qmlEngine.rootObjects(): self.qmlEngine.reload() self.paletteChanged.emit() paletteChanged = Signal() palette = Property(QPalette, lambda self: QApplication.instance().palette(), notify=paletteChanged) alternateBase = Property(QColor, lambda self: self.palette.color(QPalette.AlternateBase), notify=paletteChanged) base = Property(QColor, lambda self: self.palette.color(QPalette.Base), notify=paletteChanged) button = Property(QColor, lambda self: self.palette.color(QPalette.Button), notify=paletteChanged) buttonText = Property(QColor, lambda self: self.palette.color(QPalette.ButtonText), notify=paletteChanged) disabledButtonText = Property(QColor, lambda self: self.palette.color(QPalette.Disabled, QPalette.ButtonText), notify=paletteChanged) highlight = Property(QColor, lambda self: self.palette.color(QPalette.Highlight), notify=paletteChanged) disabledHighlight = Property(QColor, lambda self: self.palette.color(QPalette.Disabled, QPalette.Highlight), notify=paletteChanged) highlightedText = Property(QColor, lambda self: self.palette.color(QPalette.HighlightedText), notify=paletteChanged) disabledHighlightedText = Property(QColor, lambda self: self.palette.color(QPalette.Disabled, QPalette.HighlightedText), notify=paletteChanged) link = Property(QColor, lambda self: self.palette.color(QPalette.Link), notify=paletteChanged) mid = Property(QColor, lambda self: self.palette.color(QPalette.Mid), notify=paletteChanged) shadow = Property(QColor, lambda self: self.palette.color(QPalette.Shadow), notify=paletteChanged) text = Property(QColor, lambda self: self.palette.color(QPalette.Text), notify=paletteChanged) disabledText = Property(QColor, lambda self: self.palette.color(QPalette.Disabled, QPalette.Text), notify=paletteChanged) toolTipBase = Property(QColor, lambda self: self.palette.color(QPalette.ToolTipBase), notify=paletteChanged) toolTipText = Property(QColor, lambda self: self.palette.color(QPalette.ToolTipText), notify=paletteChanged) window = Property(QColor, lambda self: self.palette.color(QPalette.Window), notify=paletteChanged) windowText = Property(QColor, lambda self: self.palette.color(QPalette.WindowText), notify=paletteChanged) disabledWindowText = Property(QColor, lambda self: self.palette.color(QPalette.Disabled, QPalette.WindowText), notify=paletteChanged) ================================================ FILE: meshroom/ui/qml/AboutDialog.qml ================================================ import QtQuick import QtQuick.Controls import QtQuick.Layouts import Utils 1.0 import MaterialIcons 2.2 /** * Meshroom "About" window */ Dialog { id: root x: parent.width / 2 - width / 2 width: 600 // Fade in transition enter: Transition { NumberAnimation { property: "opacity" from: 0.0 to: 1.0 } } modal: true closePolicy: Dialog.CloseOnEscape | Dialog.CloseOnPressOutside padding: 30 topPadding: 0 // Header provides top padding header: Pane { background: Item {} MaterialToolButton { text: MaterialIcons.close anchors.right: parent.right onClicked: root.close() } } ColumnLayout { width: parent.width spacing: 6 // Logo + Version info Column { Layout.fillWidth: true Image { anchors.horizontalCenter: parent.horizontalCenter source: "../img/meshroom-tagline-vertical.svg" sourceSize: Qt.size(222, 180) } TextArea { id: config width: parent.width readOnly: true horizontalAlignment: TextArea.AlignHCenter selectByKeyboard: true selectByMouse: true text: "Version " + Qt.application.version + "\n" + MeshroomApp.systemInfo["platform"] + " \n" + MeshroomApp.systemInfo["python"] + "\n" + MeshroomApp.systemInfo["pyside"] } } SystemPalette { id: systemPalette } // Links Row { spacing: 4 Layout.alignment: Qt.AlignHCenter MaterialToolButton { text: MaterialIcons.public_ font.pointSize: 21 ToolTip.text: "AliceVision Website" onClicked: Qt.openUrlExternally("https://alicevision.org") } MaterialToolButton { text: MaterialIcons.favorite font.pointSize: 21 ToolTip.text: "Donate to get a better software" onClicked: Qt.openUrlExternally("https://alicevision.org/association/#donate") } ToolButton { icon.source: "../img/github-mark-white.svg" icon.width: 24 icon.height: 24 icon.color: palette.text ToolTip.text: "Meshroom on Github" ToolTip.visible: hovered onClicked: Qt.openUrlExternally("https://github.com/alicevision/Meshroom") } MaterialToolButton { text: MaterialIcons.bug_report font.pointSize: 21 ToolTip.text: "Report a Bug (GitHub account required)" property string body: "**Configuration**\n\n" + config.text onClicked: Qt.openUrlExternally("https://github.com/alicevision/Meshroom/issues/new?body="+body) } MaterialToolButton { text: MaterialIcons.forum font.pointSize: 21 ToolTip.text: "Public Mailing-List (open discussions, use-cases, problems, best practices...)" onClicked: Qt.openUrlExternally("https://groups.google.com/forum/#!forum/alicevision") } MaterialToolButton { text: MaterialIcons.mail font.pointSize: 21 ToolTip.text: "Private Contact (alicevision-team@googlegroups.com)" onClicked: Qt.openUrlExternally("mailto:alicevision-team@googlegroups.com") } } // Copyright RowLayout { spacing: 2 Layout.alignment: Qt.AlignHCenter Label { font.family: MaterialIcons.fontFamily text: MaterialIcons.copyright font.pointSize: 10 } Label { text: "2010-2025 AliceVision contributors" } } // Spacer Rectangle { width: 50 height: 1 color: systemPalette.mid Layout.alignment: Qt.AlignHCenter } // OpenSource licenses Label { text: "Changelog & Open Source Licenses" Layout.alignment: Qt.AlignHCenter } ListView { Layout.fillWidth: true implicitHeight: childrenRect.height spacing: 2 interactive: false model: MeshroomApp.changelogModel.concat(MeshroomApp.licensesModel) // Exclusive ButtonGroup for licenses entries ButtonGroup { id: licensesGroup; exclusive: true } delegate: ColumnLayout { width: ListView.view.width Button { id: sectionButton flat: true text: modelData.title font.pointSize: 10 font.bold: true checkable: true ButtonGroup.group: licensesGroup Layout.alignment: Qt.AlignHCenter } Loader { Layout.fillWidth: true active: sectionButton.checked Layout.preferredHeight: active ? 210 : 0 visible: active // Log display sourceComponent: ScrollView { Component.onCompleted: { // Try to load the local file var url = Filepath.stringToUrl(modelData.localUrl) // Fallback to the online url if file is not found if (!Filepath.exists(url)) url = modelData.onlineUrl Request.get(url, function(xhr) { if (xhr.readyState === XMLHttpRequest.DONE) { // Status is OK if (xhr.status === 200) textArea.text = MeshroomApp.markdownToHtml(xhr.responseText) else textArea.text = "Could not load license file. Available online at "+ url + "." } }) } background: Rectangle { color: palette.base } TextArea { id: textArea readOnly: true implicitWidth: parent.implicitWidth selectByMouse: true selectByKeyboard: true wrapMode: TextArea.WrapAnywhere textFormat: TextEdit.RichText onLinkActivated: function(link) { Qt.openUrlExternally(link) } } } } } } } } ================================================ FILE: meshroom/ui/qml/Application.qml ================================================ import QtCore import QtQuick import QtQuick.Controls import QtQuick.Layouts import QtQml.Models import Qt.labs.platform as Platform import QtQuick.Dialogs import GraphEditor 1.0 import MaterialIcons 2.2 import Utils 1.0 import Controls 1.0 Page { id: root property alias computingAtExitDialog: computingAtExitDialog property alias unsavedDialog: unsavedDialog property alias workspaceView: workspaceView readonly property var scenefile: _currentScene ? _currentScene.graph.filepath : ""; onScenefileChanged: { // Check if we are not currently saving and emit the currentProjectChanged signal if (! _currentScene.graph.isSaving) { // Refresh the NodeEditor nodeEditor.refresh(); } } Settings { id: settingsUILayout category: "UILayout" property alias showGraphEditor: graphEditorVisibilityCB.checked property alias showImageViewer: imageViewerVisibilityCB.checked property alias showViewer3D: viewer3DVisibilityCB.checked property alias showImageGallery: imageGalleryVisibilityCB.checked property alias showTextViewer: textViewerVisibilityCB.checked } Settings { id: nodeActionsSettings category: "NodeActions" property alias confirmBeforeDelete: nodeActionsConfirmDelete.checked } property url imagesFolder: { var recentImportedImagesFolders = MeshroomApp.recentImportedImagesFolders if (recentImportedImagesFolders.length > 0) { for (var i = 0; i < recentImportedImagesFolders.length; i++) { if (Filepath.exists(recentImportedImagesFolders[i])) return Filepath.stringToUrl(recentImportedImagesFolders[i]) else MeshroomApp.removeRecentImportedImagesFolder(Filepath.stringToUrl(recentImportedImagesFolders[i])) } } return "" } Component { id: invalidFilepathDialog MessageDialog { title: "Invalid Filepath" required property string filepath preset: "Warning" text: "The provided filepath is not valid." detailedText: "Filepath: " + filepath helperText: "Please provide a valid filepath to save the file." standardButtons: Dialog.Ok onClosed: destroy() } } Component { id: permissionsDialog MessageDialog { title: "Permission Denied" required property string filepath preset: "Warning" text: "The location does not exist or you do not have necessary permissions to save to the provided filepath." detailedText: "Filepath: " + filepath helperText: "Please check the location or permissions and try again or choose a different location." standardButtons: Dialog.Ok onClosed: destroy() } } function validateFilepathForSave(filepath: string, sourceSaveDialog): bool { /** * Return true if `filepath` is valid for saving a file to disk. * 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"; // Provided filename is not valid if (emptyFilename) { // Instantiate the Warning Dialog with the provided filepath const warningDialog = invalidFilepathDialog.createObject(root, {"filepath": Filepath.urlToString(filepath)}); // And open the dialog warningDialog.closed.connect(sourceSaveDialog.open); warningDialog.open(); return false; } // Check if the user has access to the directory where the file is to be saved const hasPermission = Filepath.accessible(Filepath.dirname(filepath)); // Either the directory does not exist or is inaccessible for the user if (!hasPermission) { // Intantiate the permissions dialog with the provided filepath const warningDialog = permissionsDialog.createObject(root, {"filepath": Filepath.urlToString(filepath)}); // Connect and show the dialog warningDialog.closed.connect(sourceSaveDialog.open); warningDialog.open(); return false; } // Everything is valid return true; } // File dialogs Platform.FileDialog { id: saveFileDialog property var _callback: undefined signal closed(var result) title: "Save File" nameFilters: ["Meshroom Graphs (*.mg)"] defaultSuffix: ".mg" fileMode: Platform.FileDialog.SaveFile onAccepted: { if (!validateFilepathForSave(currentFile, saveFileDialog)) { return; } // Only save a valid file _currentScene.saveAs(currentFile) MeshroomApp.addRecentProjectFile(currentFile.toString()) closed(Platform.Dialog.Accepted) fireCallback(Platform.Dialog.Accepted) } onRejected: { closed(Platform.Dialog.Rejected) fireCallback(Platform.Dialog.Rejected) } function fireCallback(rc) { // Call the callback and reset it if (_callback) _callback(rc) _callback = undefined } // Open the unsaved dialog warning with an optional // callback to fire when the dialog is accepted/discarded function prompt(callback) { _callback = callback open() } } Platform.FileDialog { id: saveTemplateDialog signal closed(var result) title: "Save Template" nameFilters: ["Meshroom Graphs (*.mg)"] defaultSuffix: ".mg" fileMode: Platform.FileDialog.SaveFile onAccepted: { if (!validateFilepathForSave(currentFile, saveTemplateDialog)) { return; } // Only save a valid template _currentScene.saveAsTemplate(currentFile) closed(Platform.Dialog.Accepted) MeshroomApp.reloadTemplateList() } onRejected: closed(Platform.Dialog.Rejected) } Platform.FileDialog { id: loadTemplateDialog title: "Load Template" nameFilters: ["Meshroom Graphs (*.mg)"] onAccepted: { // Open the template as a regular file if (_currentScene.load(currentFile)) { MeshroomApp.addRecentProjectFile(currentFile.toString()) } } } Platform.FileDialog { id: importImagesDialog title: "Import Images" fileMode: Platform.FileDialog.OpenFiles nameFilters: [] onAccepted: { _currentScene.importImagesUrls(currentFiles) imagesFolder = Filepath.dirname(currentFiles[0]) MeshroomApp.addRecentImportedImagesFolder(imagesFolder) } } Platform.FileDialog { id: importProjectDialog title: "Import Project" fileMode: Platform.FileDialog.OpenFile nameFilters: ["Meshroom Graphs (*.mg)"] onAccepted: { graphEditor.uigraph.importProject(currentFile) } } Item { id: computeManager // Evaluate if graph computation can be submitted externally property bool canSubmit: _currentScene ? _currentScene.canSubmit // current setup allows to compute externally && _currentScene.graph.filepath : // graph is saved on disk false function compute(nodes, force) { if (!force && !_currentScene.graph.filepath) { unsavedComputeDialog.selectedNodes = nodes; unsavedComputeDialog.open(); } else { try { _currentScene.execute(nodes) } catch (error) { const data = ErrorHandler.analyseError(error) if (data.context === "COMPUTATION") computeSubmitErrorDialog.openError(data.type, data.msg, nodes) } } } function submit(nodes) { if (!canSubmit) { unsavedSubmitDialog.open() } else { try { _currentScene.submit(nodes) } catch (error) { const data = ErrorHandler.analyseError(error) if (data.context === "SUBMITTING") computeSubmitErrorDialog.openError(data.type, data.msg, nodes) } } } MessageDialog { id: computeSubmitErrorDialog property string errorType // Used to specify signals' behavior property var currentNode: null function openError(type, msg, node) { errorType = type switch (type) { case "Already Submitted": { this.setupPendingStatusError(msg, node) break } case "Compatibility Issue": { this.setupCompatibilityIssue(msg) break } default: { this.onlyDisplayError(msg) } } this.open() } function onlyDisplayError(msg) { text = msg standardButtons = Dialog.Ok } function setupPendingStatusError(msg, node) { currentNode = node text = msg + "\n\nDo you want to Clear Pending Status and Start Computing?" standardButtons = (Dialog.Ok | Dialog.Cancel) } function setupCompatibilityIssue(msg) { text = msg + "\n\nDo you want to open the Compatibility Manager?" standardButtons = (Dialog.Ok | Dialog.Cancel) } canCopy: false icon.text: MaterialIcons.warning parent: Overlay.overlay preset: "Warning" title: "Computation/Submitting" text: "" onAccepted: { switch (errorType) { case "Already Submitted": { close() _currentScene.graph.clearSubmittedNodes() _currentScene.execute(currentNode) break } case "Compatibility Issue": { close() compatibilityManager.open() break } default: close() } } onRejected: close() } MessageDialog { id: unsavedComputeDialog property var selectedNodes: null canCopy: false icon.text: MaterialIcons.warning parent: Overlay.overlay preset: "Warning" title: "Unsaved Project" text: "Saving the project is required." helperText: "Choose a location to save the project, or use the default temporary path." standardButtons: Dialog.Discard | Dialog.Cancel | Dialog.Save Component.onCompleted: { // Set up discard button text standardButton(Dialog.Discard).text = "Continue in Temp Folder" standardButton(Dialog.Save).text = "Save As" } onDiscarded: { _currentScene.saveAsTemp() close() computeManager.compute(selectedNodes, true) } onAccepted: { initFileDialogFolder(saveFileDialog) saveFileDialog.prompt(function(rc) { computeManager.compute(selectedNodes, true) }) } } MessageDialog { id: unsavedSubmitDialog canCopy: false icon.text: MaterialIcons.warning parent: Overlay.overlay preset: "Warning" title: "Unsaved Project" text: "The project cannot be submitted if it remains unsaved." helperText: "Save the project to be able to submit it?" standardButtons: Dialog.Cancel | Dialog.Save onDiscarded: close() onAccepted: saveAsAction.trigger() } MessageDialog { id: fileModifiedDialog canCopy: false icon.text: MaterialIcons.warning parent: Overlay.overlay preset: "Warning" title: "File Modified" text: "The file has been modified by another instance." detailedText: "Do you want to overwrite the file?" // Add a reload file button next to the save button footer: DialogButtonBox { position: DialogButtonBox.Footer standardButtons: Dialog.Save | Dialog.Cancel Button { text: "Reload File" onClicked: { _currentScene.load(_currentScene.graph.filepath) fileModifiedDialog.close() } } } onAccepted: _currentScene.save() onDiscarded: close() } } // Message dialogs MessageDialog { id: unsavedDialog property var _callback: undefined title: (_currentScene ? Filepath.basename(_currentScene.graph.filepath) : "") || "Unsaved Project" preset: "Info" canCopy: false text: _currentScene && _currentScene.graph.filepath ? "Current project has unsaved modifications." : "Current project has not been saved." helperText: _currentScene && _currentScene.graph.filepath ? "Would you like to save those changes?" : "Would you like to save this project?" standardButtons: Dialog.Save | Dialog.Cancel | Dialog.Discard onDiscarded: { close() // BUG ? discard does not close window fireCallback() } onRejected: { _window.isClosing = false } onAccepted: { // Save current file if (saveAction.enabled && _currentScene.graph.filepath) { saveAction.trigger() fireCallback() } // Open "Save As" dialog else { saveFileDialog.prompt(function(rc) { if (rc === Platform.Dialog.Accepted) fireCallback() }) } } function fireCallback() { // Call the callback and reset it if (_callback) _callback() _callback = undefined } // Open the unsaved dialog warning with an optional // callback to fire when the dialog is accepted/discarded function prompt(callback) { _callback = callback open() } } MessageDialog { id: computingAtExitDialog title: "Operation in progress" modal: true canCopy: false Label { text: "Please stop any local computation before exiting Meshroom" } } MessageDialog { // Popup displayed while the application // is busy building intrinsics while importing images id: buildingIntrinsicsDialog modal: true visible: _currentScene ? _currentScene.buildingIntrinsics : false closePolicy: Popup.NoAutoClose title: "Initializing Cameras" icon.text: MaterialIcons.camera icon.font.pointSize: 10 canCopy: false standardButtons: Dialog.NoButton detailedText: "Extracting images metadata and creating Camera intrinsics..." ProgressBar { indeterminate: true Layout.fillWidth: true } } AboutDialog { id: aboutDialog } DialogsFactory { id: dialogsFactory } CompatibilityManager { id: compatibilityManager uigraph: _currentScene } // Actions Action { id: removeAllImagesAction property string tooltip: "Remove all the images from the current CameraInit group" text: "Remove All Images" onTriggered: { _currentScene.removeAllImages() _currentScene.selectedViewId = "-1" } } Action { id: removeImagesFromAllGroupsAction property string tooltip: "Remove all the images from all the CameraInit groups" text: "Remove Images From All CameraInit Nodes" onTriggered: { _currentScene.removeImagesFromAllGroups() _currentScene.selectedViewId = "-1" } } Action { id: reloadPluginsAction property string tooltip: "Reload the source code for all nodes from all registered plugins" text: "Reload Plugins Source Code" shortcut: "Ctrl+Shift+R" onTriggered: { statusBar.showMessage("Reloading plugins...") _currentScene.reloadPlugins() // This will handle the message to show that it finished properly } } Action { id: undoAction property string tooltip: 'Undo "' + (_currentScene ? _currentScene.undoStack.undoText : "Unknown") + '"' text: "Undo" shortcut: "Ctrl+Z" enabled: _currentScene ? _currentScene.undoStack.canUndo && _currentScene.undoStack.isUndoableIndex : false onTriggered: _currentScene.undoStack.undo() } Action { id: redoAction property string tooltip: 'Redo "' + (_currentScene ? _currentScene.undoStack.redoText : "Unknown") + '"' text: "Redo" shortcut: "Ctrl+Shift+Z" enabled: _currentScene ? _currentScene.undoStack.canRedo && !_currentScene.undoStack.lockedRedo : false onTriggered: _currentScene.undoStack.redo() } Action { id: cutAction property string tooltip: "Cut Selected Node(s)" text: "Cut Node(s)" enabled: _currentScene ? _currentScene.nodeSelection.hasSelection : false onTriggered: { graphEditor.copyNodes() graphEditor.uigraph.removeSelectedNodes() } } Action { id: copyAction property string tooltip: "Copy Selected Node(s)" text: "Copy Node(s)" enabled: _currentScene ? _currentScene.nodeSelection.hasSelection : false onTriggered: graphEditor.copyNodes() } Action { id: pasteAction property string tooltip: "Paste the clipboard content to the project if it contains valid nodes" text: "Paste Node(s)" onTriggered: graphEditor.pasteNodes() } Action { id: loadTemplateAction property string tooltip: "Load a template like a regular project file (any \"CopyFiles\" node will be displayed)" text: "Load Template" onTriggered: { ensureSaved(function() { initFileDialogFolder(loadTemplateDialog) loadTemplateDialog.open() }) } } header: RowLayout { spacing: 0 MaterialToolButton { id: homeButton text: MaterialIcons.home font.pointSize: 18 background: Rectangle { color: homeButton.hovered ? activePalette.highlight : Qt.darker(activePalette.window, 1.15) border.color: Qt.darker(activePalette.window, 1.15) } onClicked: { if (!ensureNotComputing()) return ensureSaved(function() { _currentScene.clear() if (mainStack.depth == 1) mainStack.replace("Homepage.qml") else mainStack.pop() }) } } MenuBar { palette.window: Qt.darker(activePalette.window, 1.15) Menu { title: "File" Action { id: newAction text: "New" shortcut: "Ctrl+N" onTriggered: ensureSaved(function() { _currentScene.new() }) } Menu { id: newPipelineMenu title: "New Pipeline" enabled: newPipelineMenuItems.model !== undefined && newPipelineMenuItems.model.length > 0 property int maxWidth: 1000 property int fullWidth: { var result = 0; for (var i = 0; i < count; ++i) { var item = itemAt(i) result = Math.max(item.implicitWidth + item.padding * 2, result) } return result; } implicitWidth: fullWidth Repeater { id: newPipelineMenuItems model: MeshroomApp.pipelineTemplateFiles MenuItem { onTriggered: ensureSaved(function() { _currentScene.new(modelData["key"]) }) text: fileTextMetrics.elidedText TextMetrics { id: fileTextMetrics text: modelData["name"] elide: Text.ElideLeft elideWidth: newPipelineMenu.maxWidth } ToolTip { id: toolTip delay: 200 text: modelData["path"] visible: hovered x: newPipelineMenu.implicitWidth y: newPipelineMenuItems.implicitHeight } } } } Action { id: openActionItem text: "Open" shortcut: "Ctrl+O" onTriggered: ensureSaved(function() { initFileDialogFolder(openFileDialog) openFileDialog.open() }) } Menu { id: openRecentMenu title: "Open Recent" enabled: recentFilesMenuItems.model !== undefined && recentFilesMenuItems.model.length > 0 property int maxWidth: 1000 property int fullWidth: { var result = 0; for (var i = 0; i < count; ++i) { var item = itemAt(i) result = Math.max(item.implicitWidth + item.padding * 2, result) } return result } implicitWidth: fullWidth Repeater { id: recentFilesMenuItems model: MeshroomApp.recentProjectFiles MenuItem { enabled: modelData["status"] != 0 onTriggered: ensureSaved(function() { openRecentMenu.dismiss() if (_currentScene.load(modelData["path"])) { MeshroomApp.addRecentProjectFile(modelData["path"]) } }) text: fileTextMetrics.elidedText TextMetrics { id: fileTextMetrics text: modelData["path"] elide: Text.ElideLeft elideWidth: openRecentMenu.maxWidth } } } } MenuSeparator { } Action { id: saveAction text: "Save" shortcut: "Ctrl+S" enabled: _currentScene ? (_currentScene.graph && !_currentScene.graph.filepath) || !_currentScene.undoStack.clean : false onTriggered: { if (_currentScene.graph.filepath) { // Get current time date var date = _currentScene.graph.getFileDateVersionFromPath(_currentScene.graph.filepath) // Check if the file has been modified by another instance if (_currentScene.graph.fileDateVersion !== date) { fileModifiedDialog.open() } else _currentScene.save() } else { initFileDialogFolder(saveFileDialog) saveFileDialog.open() } } } Action { id: saveAsAction text: "Save As..." shortcut: "Ctrl+Shift+S" onTriggered: { initFileDialogFolder(saveFileDialog) saveFileDialog.open() } } Action { id: saveNewVersionAction text: "Save New Version" shortcut: "Ctrl+Alt+S" enabled: _currentScene && _currentScene.graph && _currentScene.graph.filepath onTriggered: { _currentScene.saveAsNewVersion() MeshroomApp.addRecentProjectFile(_currentScene.graph.filepath) } } MenuSeparator { } Action { id: importImagesAction text: "Import Images" shortcut: "Ctrl+I" onTriggered: { initFileDialogFolder(importImagesDialog, true) importImagesDialog.open() } } MenuItem { action: removeAllImagesAction ToolTip { visible: parent.hovered text: removeAllImagesAction.tooltip x: parent.implicitWidth y: 0 } } MenuSeparator { } Menu { id: advancedMenu 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" shortcut: Shortcut { sequence: "Ctrl+Shift+I" context: Qt.ApplicationShortcut onActivated: importProjectAction.triggered() } onTriggered: { initFileDialogFolder(importProjectDialog) importProjectDialog.open() } } MenuItem { action: removeImagesFromAllGroupsAction ToolTip { visible: parent.hovered text: removeImagesFromAllGroupsAction.tooltip x: advancedMenu.implicitWidth y: 0 } } MenuItem { action: reloadPluginsAction ToolTip { visible: parent.hovered text: reloadPluginsAction.tooltip x: advancedMenu.implicitWidth y: 0 } } MenuSeparator { } Menu { id: nodeActionsSettingsMenu title: "NodeActions Settings" implicitWidth: 250 MenuItem { id: nodeActionsConfirmDelete checkable: true checked: false text: "Confirm Before Deleting Data" ToolTip { visible: parent.hovered text: "Show a confirmation popup before deleting the node data" x: nodeActionsSettingsMenu.width y: 0 } } } } MenuSeparator { } Action { text: "Quit" onTriggered: _window.close() } } Menu { title: "Edit" MenuItem { action: undoAction ToolTip { visible: parent.hovered && undoAction.enabled text: undoAction.tooltip x: parent.implicitWidth y: 0 } } MenuItem { action: redoAction ToolTip { visible: parent.hovered && redoAction.enabled text: redoAction.tooltip x: parent.implicitWidth y: 0 } } MenuItem { action: cutAction ToolTip { visible: parent.hovered && cutAction.enabled text: cutAction.tooltip x: parent.implicitWidth y: 0 } } MenuItem { action: copyAction ToolTip { visible: parent.hovered && copyAction.enabled text: copyAction.tooltip x: parent.implicitWidth y: 0 } } MenuItem { action: pasteAction ToolTip { visible: parent.hovered && pasteAction.enabled text: pasteAction.tooltip x: parent.implicitWidth y: 0 } } } Menu { title: "View" MenuItem { id: graphEditorVisibilityCB text: "Graph Editor" checkable: true checked: true } MenuItem { id: imageViewerVisibilityCB text: "Image Viewer" checkable: true checked: true } MenuItem { id: viewer3DVisibilityCB text: "3D Viewer" checkable: true checked: true } MenuItem { id: imageGalleryVisibilityCB text: "Image Gallery" checkable: true checked: true } MenuItem { id: textViewerVisibilityCB text: "Text Viewer" checkable: true checked: false } MenuSeparator {} Action { text: "Fullscreen" checkable: true checked: _window.visibility == ApplicationWindow.FullScreen shortcut: "Ctrl+F" onTriggered: _window.visibility == ApplicationWindow.FullScreen ? _window.showNormal() : showFullScreen() } } Menu { title: "Process" Action { text: "Compute All Nodes" onTriggered: computeManager.compute(null) enabled: _currentScene ? !_currentScene.computingLocally : false } Action { text: "Submit All Nodes" onTriggered: computeManager.submit(null) enabled: _currentScene ? _currentScene.canSubmit : false } MenuSeparator {} Action { text: "Stop Computation" onTriggered: _currentScene.stopExecution() enabled: _currentScene ? _currentScene.computingLocally : false } MenuSeparator {} Menu { id: submitterSelectionMenu title: "Submitter Selection" enabled: submitterItems.model !== undefined && submitterItems.model.length > 0 Repeater { id: submitterItems model: MeshroomApp.submittersListModel RadioButton { text: modelData["name"] checked: modelData["isDefault"] onClicked: MeshroomApp.setDefaultSubmitter(modelData["name"]) } } } } Menu { title: "Help" Action { text: "Online Documentation" onTriggered: Qt.openUrlExternally("https://meshroom-manual.readthedocs.io") } Action { text: "About Meshroom" onTriggered: aboutDialog.open() // Should be StandardKey.HelpContents, but for some reason it is not stable // (may cause crash, requires pressing F1 twice after closing the popup) shortcut: "F1" } } } Rectangle { Layout.fillWidth: true Layout.fillHeight: true color: Qt.darker(activePalette.window, 1.15) } Row { // Process buttons MaterialToolButton { id: processButton font.pointSize: 18 text: !(_currentScene.computingLocally) ? MaterialIcons.send : MaterialIcons.cancel_schedule_send ToolTip.text: !(_currentScene.computingLocally) ? "Compute" : "Stop Computing" ToolTip.visible: hovered background: Rectangle { color: processButton.hovered ? activePalette.highlight : Qt.darker(activePalette.window, 1.15) border.color: Qt.darker(activePalette.window, 1.15) } onClicked: _currentScene.computingLocally ? _currentScene.stopExecution() : computeManager.compute(null) } MaterialToolButton { id: submitButton font.pointSize: 18 visible: _currentScene ? _currentScene.canSubmit : false text: !(_currentScene.computingExternally) ? MaterialIcons.rocket_launch : MaterialIcons.paragliding ToolTip.text: !(_currentScene.computingExternally) ? "Submit on Render Farm" : "Interrupt Job" ToolTip.visible: hovered background: Rectangle { color: submitButton.hovered ? activePalette.highlight : Qt.darker(activePalette.window, 1.15) border.color: Qt.darker(activePalette.window, 1.15) } onClicked: _currentScene.computingExternally ? _currentScene.stopExecution() : computeManager.submit(null) } } Rectangle { Layout.fillWidth: true Layout.fillHeight: true color: Qt.darker(activePalette.window, 1.15) } // CompatibilityManager indicator ToolButton { id: compatibilityIssuesButton visible: compatibilityManager.issueCount text: MaterialIcons.warning font.family: MaterialIcons.fontFamily palette.buttonText: "#FF9800" font.pointSize: 12 onClicked: compatibilityManager.open() ToolTip.text: "Compatibility Issues" ToolTip.visible: hovered background: Rectangle { color: compatibilityIssuesButton.hovered ? activePalette.highlight : Qt.darker(activePalette.window, 1.15) border.color: Qt.darker(activePalette.window, 1.15) } } } footer: ToolBar { id: footer padding: 1 leftPadding: 4 rightPadding: 4 palette.window: Qt.darker(activePalette.window, 1.15) RowLayout { anchors.fill: parent spacing: 0 MaterialToolButton { font.pointSize: 8 text: MaterialIcons.folder_open ToolTip.text: "Open Cache Folder" onClicked: Qt.openUrlExternally(Filepath.stringToUrl(_currentScene.graph.cacheDir)) } TextField { readOnly: true selectByMouse: true text: _currentScene ? _currentScene.graph.cacheDir : "Unknown" color: Qt.darker(palette.text, 1.2) background: Item {} } // Spacer to push status bar to the right Item { Layout.fillWidth: true } StatusBar { id: statusBar objectName: "statusBar" // Expose to python height: parent.height defaultIcon : MaterialIcons.comment } } } Connections { target: _currentScene // Bind messages to DialogsFactory function createDialog(func, message) { var dialog = func(_window) // Set text afterwards to avoid dialog sizing issues dialog.title = message.title dialog.text = message.text dialog.detailedText = message.detailedText } function onGraphChanged() { // Open CompatibilityManager after file loading if any issue is detected if (compatibilityManager.issueCount) compatibilityManager.open() // Trigger fit to visualize all nodes graphEditor.fit() } function onInfo() { createDialog(dialogsFactory.info, arguments[0]) } function onWarning() { createDialog(dialogsFactory.warning, arguments[0]) } function onError() { createDialog(dialogsFactory.error, arguments[0]) } } ColumnLayout { anchors.fill: parent spacing: 4 // "ProgressBar" reflecting status of all the chunks in the graph, in their process order NodeChunks { id: chunksListView height: 6 Layout.fillWidth: true model: _currentScene ? _currentScene.sortedDFSChunks : null highlightChunks: false } MSplitView { id: topBottomSplit Layout.fillHeight: true Layout.fillWidth: true orientation: Qt.Vertical // Setup global tooltip style ToolTip.toolTip.background: Rectangle { color: activePalette.base; border.color: activePalette.mid } WorkspaceView { id: workspaceView SplitView.fillHeight: true SplitView.preferredHeight: 300 SplitView.minimumHeight: 80 currentScene: _currentScene readOnly: _currentScene ? _currentScene.computing : false function viewNode(node, mouse) { // 2D viewer viewer2D.tryLoadNode(node) // 3D viewer // By default we only display the first 3D item, except if it has the semantic flag "3D" var alreadyDisplay = false for (var i = 0; i < node.attributes.count; i++) { var attr = node.attributes.at(i) if (attr.isOutput && attr.desc.semantic !== "image") if (!alreadyDisplay || attr.desc.semantic == "3d") { if (workspaceView.viewIn3D(attr, mouse)) alreadyDisplay = true } } // Text viewer - open the first text output when the node has only text outputs if (node.hasTextOutput && !node.hasImageOutput && !node.hasSequenceOutput && !node.has3DOutput) { for (var j = 0; j < node.attributes.count; j++) { var textAttr = node.attributes.at(j) if (textAttr.isOutput && textAttr.isTextDisplayable) { workspaceView.viewInText(textAttr) break } } } } function viewIn2D(attribute, mouse) { settingsUILayout.showImageViewer = true workspaceView.mediaViewerTabIndex = 0 workspaceView.viewer2D.tryLoadNode(attribute.node) workspaceView.viewer2D.setAttributeName(attribute.name) } function viewInText(attribute) { settingsUILayout.showTextViewer = true // Text Viewer is at index 1 when Image Viewer is also shown, else at index 0 workspaceView.mediaViewerTabIndex = settingsUILayout.showImageViewer ? 1 : 0 workspaceView.viewerText.source = Filepath.stringToUrl(attribute.value) } function viewIn3D(attribute, mouse) { if (!panel3dViewer || (!attribute.node.has3DOutput && !attribute.node.hasAttribute("useBoundingBox"))) { return false } var loaded = panel3dViewer.viewer3D.view(attribute) // solo media if Control modifier was held if (loaded && mouse && mouse.modifiers & Qt.ControlModifier) { panel3dViewer.viewer3D.solo(attribute) } return loaded } function viewAttributeInViewer(mouse, attribute) { /* Display the current attribute in the corresponding viewer */ if (attribute.is2dDisplayable) { workspaceView.viewIn2D(attribute, mouse) } else if (attribute.is3dDisplayable) { workspaceView.viewIn3D(attribute, mouse) } else if (attribute.isTextDisplayable) { workspaceView.viewInText(attribute) } } } MSplitView { id: bottomContainer orientation: Qt.Horizontal visible: settingsUILayout.showGraphEditor SplitView.preferredHeight: 300 SplitView.minimumHeight: 80 TabPanel { id: graphEditorPanel SplitView.fillWidth: true SplitView.minimumWidth: 350 padding: 4 tabs: ["Graph Editor", "Task Manager", "Script Editor"] headerBar: RowLayout { MaterialToolButton { text: MaterialIcons.sync ToolTip.text: "Refresh Nodes Status" ToolTip.visible: hovered font.pointSize: 11 padding: 2 onClicked: { updatingStatus = true _currentScene.forceNodesStatusUpdate() updatingStatus = false } property bool updatingStatus: false enabled: !updatingStatus } MaterialToolButton { text: MaterialIcons.more_vert font.pointSize: 11 padding: 2 onClicked: graphEditorMenu.open() checkable: true checked: graphEditorMenu.visible Menu { id: graphEditorMenu y: parent.height x: -width + parent.width MenuItem { text: "Clear Pending Status" enabled: _currentScene ? !_currentScene.computingLocally : false onTriggered: _currentScene.graph.clearSubmittedNodes(_currentScene.getSelectedNodes()) } MenuItem { text: "Force Unlock Nodes" onTriggered: _currentScene.graph.forceUnlockNodes(_currentScene.getSelectedNodes()) } Menu { title: "Auto Layout Depth" MenuItem { id: autoLayoutMinimum text: "Minimum" checkable: true checked: _currentScene.layout.depthMode === 0 ToolTip.text: "Sets the Auto Layout Depth Mode to use Node's Minimum depth" ToolTip.visible: hovered ToolTip.delay: 200 onToggled: { if (checked) { _currentScene.layout.depthMode = 0; autoLayoutMaximum.checked = false; } // Prevents cases where the user unchecks the currently checked option autoLayoutMinimum.checked = true; } } MenuItem { id: autoLayoutMaximum text: "Maximum" checkable: true checked: _currentScene.layout.depthMode === 1 ToolTip.text: "Sets the Auto Layout Depth Mode to use Node's Maximum depth" ToolTip.visible: hovered ToolTip.delay: 200 onToggled: { if (checked) { _currentScene.layout.depthMode = 1; autoLayoutMinimum.checked = false; } // Prevents cases where the user unchecks the currently checked option autoLayoutMaximum.checked = true; } } } Menu { title: "Refresh Nodes Method" MenuItem { id: enableAutoRefresh text: "Enable Auto-Refresh" checkable: true checked: _currentScene.filePollerRefresh === 0 ToolTip.text: "Check every file's status periodically" ToolTip.visible: hovered ToolTip.delay: 200 onToggled: { if (checked) { disableAutoRefresh.checked = false minimalAutoRefresh.checked = false _currentScene.filePollerRefreshChanged(0) } // Prevents cases where the user unchecks the currently checked option enableAutoRefresh.checked = true } } MenuItem { id: disableAutoRefresh text: "Disable Auto-Refresh" checkable: true checked: _currentScene.filePollerRefresh === 1 ToolTip.text: "No file status will be checked" ToolTip.visible: hovered ToolTip.delay: 200 onToggled: { if (checked) { enableAutoRefresh.checked = false minimalAutoRefresh.checked = false _currentScene.filePollerRefreshChanged(1) } // Prevents cases where the user unchecks the currently checked option disableAutoRefresh.checked = true } } MenuItem { id: minimalAutoRefresh text: "Enable Minimal Auto-Refresh" checkable: true checked: _currentScene.filePollerRefresh === 2 ToolTip.text: "Check the file status of submitted or running chunks periodically" ToolTip.visible: hovered ToolTip.delay: 200 onToggled: { if (checked) { disableAutoRefresh.checked = false enableAutoRefresh.checked = false _currentScene.filePollerRefreshChanged(2) } // Prevents cases where the user unchecks the currently checked option minimalAutoRefresh.checked = true } } } } } } GraphEditor { id: graphEditor anchors.fill: parent visible: graphEditorPanel.currentTab === 0 uigraph: _currentScene nodeTypesModel: _nodeTypes onNodeDoubleClicked: function(mouse, node) { _currentScene.setActiveNode(node); workspaceView.viewNode(node, mouse); } onComputeRequest: function(nodes) { _currentScene.forceNodesStatusUpdate(); computeManager.compute(nodes) } onSubmitRequest: function(nodes) { _currentScene.forceNodesStatusUpdate(); computeManager.submit(nodes) } onFilesDropped: function(drop, mousePosition) { var filesByType = _currentScene.getFilesByTypeFromDrop(drop.urls) if (filesByType["meshroomScenes"].length == 1) { ensureSaved(function() { if (_currentScene.handleFilesUrl(filesByType, null, mousePosition)) { MeshroomApp.addRecentProjectFile(filesByType["meshroomScenes"][0]) } }) } else { _currentScene.handleFilesUrl(filesByType, null, mousePosition) } } } TaskManager { id: taskManager anchors.fill: parent visible: graphEditorPanel.currentTab === 1 uigraph: _currentScene taskManager: _currentScene ? _currentScene.taskManager : null } ScriptEditor { id: scriptEditor anchors.fill: parent rootApplication: root visible: graphEditorPanel.currentTab === 2 } } NodeEditor { id: nodeEditor SplitView.preferredWidth: 500 SplitView.minimumWidth: 350 node: _currentScene ? _currentScene.selectedNode : null property bool computing: _currentScene ? _currentScene.computing : false property var currentAttributes: [] // Make NodeEditor readOnly when computing readOnly: node ? node.locked : false onUpgradeRequest: { var n = _currentScene.upgradeNode(node) _currentScene.selectedNode = n } onInAttributeClicked: function(srcItem, mouse, inAttributes) { _handleNavButtonClick(srcItem, mouse, inAttributes) } onOutAttributeClicked: function(srcItem, mouse, outAttributes) { _handleNavButtonClick(srcItem, mouse, outAttributes) } // NavButtonContextMenu Menu { id: navButtonContextMenu Repeater { model: nodeEditor.currentAttributes delegate: MenuItem { contentItem: Text { text: `${modelData.node.label}.${modelData.label}` elide: Text.ElideLeft color: Colors.sysPalette.text } onTriggered: { nodeEditor._selectNodesFromAttributes([nodeEditor.currentAttributes[index]]) } } } } function _selectNodesFromAttributes(attributes) { /* Retrieve the nodes from given attributes, and select its */ if ( !attributes || attributes.length == 0) { return } graphEditor.uigraph.clearNodeSelection() const nodes = attributes.map( attr => attr.node) if (attributes.length == 1) { _currentScene.selectedNode = attributes[0].node } graphEditor.uigraph.selectNodes(nodes) } function _openLinkAttributesContextMenu(srcItem, mouse, attributes) { nodeEditor.currentAttributes = attributes const srcGlobal = srcItem.mapToGlobal(0, 0) const nodeEditorGlobal = nodeEditor.mapToGlobal(0, 0) navButtonContextMenu.x = srcGlobal.x - nodeEditorGlobal.x navButtonContextMenu.y = srcGlobal.y - nodeEditorGlobal.y - 14 // TODO: Couldn't found a way to avoid padding in position. 14 = navButtonOut.paddingTop * 2 navButtonContextMenu.open() } function _handleNavButtonClick(srcItem, mouse, attributes) { if (mouse.button === Qt.RightButton) { nodeEditor._openLinkAttributesContextMenu(srcItem, mouse, attributes) return } nodeEditor._selectNodesFromAttributes(attributes) if (mouse.button === Qt.MiddleButton) { graphEditor.fit() } } onShowAttributeInViewer: function(attribute) { workspaceView.viewAttributeInViewer(null, attribute) } onAttributeDoubleClicked: function(mouse, attribute) { workspaceView.viewAttributeInViewer(mouse, attribute) } } } } } } ================================================ FILE: meshroom/ui/qml/Charts/ChartViewCheckBox.qml ================================================ import QtQuick import QtQuick.Controls /** * A custom CheckBox designed to be used in ChartView's legend. */ CheckBox { id: root property color color leftPadding: 0 font.pointSize: 8 indicator: Rectangle { width: 11 height: width border.width: 1 border.color: root.color color: "transparent" anchors.verticalCenter: parent.verticalCenter Rectangle { anchors.fill: parent anchors.margins: parent.border.width + 1 visible: parent.parent.checkState != Qt.Unchecked anchors.topMargin: parent.parent.checkState === Qt.PartiallyChecked ? 5 : 2 anchors.bottomMargin: anchors.topMargin color: parent.border.color anchors.centerIn: parent } } } ================================================ FILE: meshroom/ui/qml/Charts/ChartViewLegend.qml ================================================ import QtQuick import QtQuick.Controls import QtCharts /** * ChartViewLegend is an interactive legend component for ChartViews. * It provides a CheckBox for each series that can control its visibility, * and highlight on hovering. */ Flow { id: root // The ChartView to create the legend for property ChartView chartView // Currently hovered series property var hoveredSeries: null readonly property ButtonGroup buttonGroup: ButtonGroup { id: legendGroup exclusive: false } /// Shortcut function to clear legend function clear() { seriesModel.clear() } // Update internal ListModel when ChartView's series change Connections { target: chartView function onSeriesAdded(series) { seriesModel.append({"series": series}) } function onSeriesRemoved(series) { for (var i = 0; i < seriesModel.count; ++i) { if (seriesModel.get(i)["series"] === series) { seriesModel.remove(i) return } } } } onChartViewChanged: { clear() for (var i = 0; i < chartView.count; ++i) seriesModel.append({"series": chartView.series(i)}) } Repeater { // ChartView series cannot be accessed directly as a model. // Use an intermediate ListModel populated with those series. model: ListModel { id: seriesModel } ChartViewCheckBox { ButtonGroup.group: legendGroup checked: series.visible text: series.name color: series.color onHoveredChanged: { if (hovered && series.visible) root.hoveredSeries = series else root.hoveredSeries = null } // Hovered serie properties override states: [ State { when: series && root.hoveredSeries === series PropertyChanges { target: series; width: 5.0 } }, State { when: series && root.hoveredSeries && root.hoveredSeries !== series PropertyChanges { target: series; width: 0.2 } } ] MouseArea { anchors.fill: parent onClicked: function(mouse) { if (mouse.modifiers & Qt.ControlModifier) root.soloSeries(index) else series.visible = !series.visible } } } } /// Hide all series but the one at index 'idx' function soloSeries(idx) { for (var i = 0; i < seriesModel.count; i++) { chartView.series(i).visible = false } chartView.series(idx).visible = true } } ================================================ FILE: meshroom/ui/qml/Charts/InteractiveChartView.qml ================================================ import QtQuick import QtQuick.Layouts import QtCharts ChartView { id: root antialiasing: true Rectangle { id: plotZone x: root.plotArea.x y: root.plotArea.y width: root.plotArea.width height: root.plotArea.height color: "transparent" MouseArea { anchors.fill: parent property double degreeToScale: 1.0 / 120.0 // Default mouse scroll is 15 degree acceptedButtons: Qt.LeftButton | Qt.RightButton | Qt.MiddleButton onClicked: { root.zoomReset() } } } } ================================================ FILE: meshroom/ui/qml/Charts/qmldir ================================================ module Charts ChartViewLegend 1.0 ChartViewLegend.qml ChartViewCheckBox 1.0 ChartViewCheckBox.qml InteractiveChartView 1.0 InteractiveChartView.qml ================================================ FILE: meshroom/ui/qml/Controls/ColorChart.qml ================================================ import QtQuick import QtQuick.Controls import Utils 1.0 /** * ColorChart is a color picker based on a set of predefined colors. * It takes the form of a ToolButton that pops-up its palette when pressed. */ ToolButton { id: root property var colors: ["red", "green", "blue"] property int currentIndex: 0 signal colorPicked(var colorIndex) background: Rectangle { color: root.colors[root.currentIndex] border.width: hovered ? 1 : 0 border.color: Colors.sysPalette.midlight } onPressed: palettePopup.open() // Popup for the color palette Popup { id: palettePopup padding: 4 // Content width is missing side padding (hence the + padding*2) implicitWidth: colorChart.contentItem.width + padding * 2 // Center the current color y: -(root.height - padding) / 2 x: -colorChart.currentItem.x - padding // Colors palette ListView { id: colorChart implicitHeight: contentItem.childrenRect.height implicitWidth: contentWidth orientation: ListView.Horizontal spacing: 2 currentIndex: root.currentIndex model: root.colors // Display each color as a ToolButton with a custom background delegate: ToolButton { padding: 0 width: root.width height: root.height background: Rectangle { color: modelData // Display border of current/selected item border.width: hovered || index === colorChart.currentIndex ? 1 : 0 border.color: Colors.sysPalette.midlight } onClicked: { colorPicked(index) palettePopup.close() } } } } } ================================================ FILE: meshroom/ui/qml/Controls/ColorSelector.qml ================================================ import QtQuick import QtQuick.Controls import Utils 1.0 import MaterialIcons 2.2 /** * ColorSelector is a color picker based on a set of predefined colors. * It takes the form of a ToolButton that pops-up its palette when pressed. */ MaterialToolButton { id: root text: MaterialIcons.palette // Internal property holding when the popup remains visible and when is it toggled off property var isVisible: false property var colors: [ "#E35C03", "#FFAD7D", "#D0AE22", "#C9C770", "#3D6953", "#79C62F", "#02627E", "#2CB9CC", "#1453E6", "#507DD0", "#4D3E5C", "#A252BD", "#B61518", "#C16162", ] // When a color gets selected/chosen signal colorSelected(var color) // Toggles the visibility of the popup onPressed: toggle() function toggle() { /* * Toggles the visibility of the color palette. */ if (!isVisible) { palettePopup.open() isVisible = true } else { palettePopup.close() isVisible = false } } // Popup for the color palette Popup { id: palettePopup // The popup will not get closed unless explicitly closed closePolicy: Popup.NoAutoClose // Bounds padding: 4 width: (root.height * 4) + (padding * 4) // Center the current color on the tool button y: -height x: -width / 2 + (root.width + padding) / 2 // Layout of the Colors Grid { // Allow only 4 columns and all the colors can be adjusted in row multiples of 4 columns: 4 spacing: 2 anchors.centerIn: parent // Default -- Reset Colour button ToolButton { id: defaultButton padding: 0 width: root.height height: root.height // Emit no color so the graph sets None as the color of the Node onClicked: { root.colorSelected("") } background: Rectangle { color: "#FFFFFF" // display border of current/selected item border.width: defaultButton.hovered ? 1 : 0 border.color: "#000000" // Another Rectangle Rectangle { color: "#FF0000" width: parent.width + 8 height: 2 anchors.centerIn: parent rotation: 135 // Diagonally creating a Red line from bottom left to top right } } } // Colors palette Repeater { model: root.colors // display each color as a ToolButton with a custom background delegate: ToolButton { padding: 0 width: root.height height: root.height // Emit the model data as the color to update onClicked: { colorSelected(modelData) } // Model color as the background of the button background: Rectangle { color: modelData // display border of current/selected item border.width: hovered ? 1 : 0 border.color: "#000000" } } } } } } ================================================ FILE: meshroom/ui/qml/Controls/DelegateSelectionBox.qml ================================================ import QtQuick import Meshroom.Helpers /* A SelectionBox that can be used to select delegates in a model instantiator (Repeater, ListView...). Interesection test is done in the coordinate system of the container Item, using delegate's bounding boxes. The list of selected indices is emitted when the selection ends. */ SelectionBox { id: root // The Item instantiating the delegates. property Item modelInstantiator // The Item containing the delegates (used for coordinate mapping). property Item container // Emitted when the selection has ended, with the list of selected indices and modifiers. signal delegateSelectionEnded(list indices, int modifiers) onSelectionEnded: function(selectionRect, modifiers) { let selectedIndices = []; const mappedSelectionRect = mapToItem(container, selectionRect) for (var i = 0; i < modelInstantiator.count; ++i) { const delegate = modelInstantiator.getItemAt(i) if (!delegate) continue // For backdrop nodes, only select when the selection rect intersects the titlebar. const delegateRect = Qt.rect(delegate.x, delegate.y, delegate.width, delegate.isBackdropNode ? delegate.headerHeight : delegate.height) if (Geom2D.rectRectIntersect(mappedSelectionRect, delegateRect)) { selectedIndices.push(i) if (delegate.isBackdropNode) { let children = delegate.getChildrenIndices(true) for (var child = 0; child < children.length; ++child) { if (selectedIndices.indexOf(children[child]) === -1) { selectedIndices.push(children[child]) } } } } } delegateSelectionEnded(selectedIndices, modifiers) } } ================================================ FILE: meshroom/ui/qml/Controls/DelegateSelectionLine.qml ================================================ import QtQuick import Meshroom.Helpers /* A SelectionLine that can be used to select delegates in a model instantiator (Repeater, ListView...). Interesection test is done in the coordinate system of the container Item, using delegate's bounding boxes. The list of selected indices is emitted when the selection ends. */ SelectionLine { id: root // The Item instantiating the delegates. property Item modelInstantiator // The Item containing the delegates (used for coordinate mapping). property Item container // Emitted when the selection has ended, with the list of selected indices and modifiers. signal delegateSelectionEnded(list indices, int modifiers) onSelectionEnded: function(selectionP1, selectionP2, modifiers) { let selectedIndices = []; const mappedP1 = mapToItem(container, selectionP1); const mappedP2 = mapToItem(container, selectionP2); for (var i = 0; i < modelInstantiator.count; ++i) { const delegate = modelInstantiator.itemAt(i); if (delegate.intersectsSegment(mappedP1, mappedP2)) { selectedIndices.push(i); } } delegateSelectionEnded(selectedIndices, modifiers); } } ================================================ FILE: meshroom/ui/qml/Controls/DirectionalLightPane.qml ================================================ import QtQuick import QtQuick.Controls import QtQuick.Layouts import QtQuick.Shapes import MaterialIcons 2.2 import Utils 1.0 /** * Directional Light Pane * * @biref A small pane to control a directional light with a 2d ball controller. * * @param lightYawValue - directional light yaw (degrees) * @param lightPitchValue - directional light pitch (degrees) */ FloatingPane { id: root // yaw and pitch properties property double lightYawValue: 0 property double lightPitchValue: 0 // 2d controller display size properties readonly property real controllerSize: 80 readonly property real controllerRadius: controllerSize * 0.5 function reset() { lightYawValue = 0; lightPitchValue = 0; } // update 2d controller if yaw value changed onLightYawValueChanged: { lightBallController.update() } // update 2d controller if pitch value changed onLightPitchValueChanged: { lightBallController.update() } // pane properties anchors.margins: 0 padding: 5 ColumnLayout { anchors.fill: parent spacing: 5 // header RowLayout { // pane title Label { text: "Directional Light" font.bold: true Layout.fillWidth: true } // minimize or maximize button MaterialToolButton { id: bodyButton text: lightPaneBody.visible ? MaterialIcons.arrow_drop_down : MaterialIcons.arrow_drop_up font.pointSize: 10 ToolTip.text: lightPaneBody.visible ? "Minimize" : "Maximize" onClicked: { lightPaneBody.visible = !lightPaneBody.visible } } // reset button MaterialToolButton { id: resetButton text: MaterialIcons.refresh font.pointSize: 10 ToolTip.text: "Reset" onClicked: reset() } } // body RowLayout { id: lightPaneBody spacing: 10 // light parameters GridLayout { columns: 3 rowSpacing: 2 columnSpacing: 8 Layout.alignment: Qt.AlignBottom // light yaw Label { text: "Yaw" } TextField { id: lightYawTF text: lightYawValue.toFixed(2) selectByMouse: true horizontalAlignment: TextInput.AlignRight validator: doubleDegreeValidator onEditingFinished: { lightYawValue = lightYawTF.text } ToolTip.text: "Light yaw (degrees)." ToolTip.visible: hovered Layout.preferredWidth: textMetricsDegreeValue.width } Label { text: "°" } // light pitch Label { text: "Pitch" } TextField { id: lightPitchTF text: lightPitchValue.toFixed(2) selectByMouse: true horizontalAlignment: TextInput.AlignRight validator: doubleDegreeValidator onEditingFinished: { lightPitchValue = lightPitchTF.text } ToolTip.text: "Light pitch (degrees)." ToolTip.visible: hovered Layout.preferredWidth: textMetricsDegreeValue.width } Label { text: "°" } } // directional light ball controller Rectangle { id: lightBallController anchors.margins: 0 width: controllerSize height: controllerSize radius: 180 // circle color: "#FF000000" Layout.rightMargin: 5 Layout.leftMargin: 5 Layout.bottomMargin: 5 function update() { // get point from light yaw and pitch var y = (lightPitchValue / 90 * controllerRadius) var xMax = Math.sqrt(controllerRadius * controllerRadius - y * y) // get sphere maximum x coordinate var x = (lightYawValue / 90 * xMax) // get angle and distance var angleRad = Math.atan2(y, x) var distance = Math.sqrt(x * x + y * y) // avoid controller overflow if(distance > controllerRadius) { x = controllerRadius * Math.cos(angleRad) y = controllerRadius * Math.sin(angleRad) } // update light point lightPoint.x = lightPoint.startOffset + x lightPoint.y = lightPoint.startOffset + y } // light ball controller shapes Shape { anchors.centerIn: parent width: parent.width height: parent.height // ball shape ShapePath { strokeWidth: 0 // shade gradient fillGradient: RadialGradient { centerX: lightPoint.x + lightPoint.radius centerY: lightPoint.y + lightPoint.radius centerRadius: controllerSize focalX: (lightPoint.x - lightPoint.startOffset) * 0.75 + lightPoint.startOffset + lightPoint.radius focalY: (lightPoint.y - lightPoint.startOffset) * 0.75 + lightPoint.startOffset + lightPoint.radius focalRadius: 2 GradientStop { position: 0.00; color: "#FFCCCCCC" } GradientStop { position: 0.05; color: "#FFAAAAAA" } GradientStop { position: 0.50; color: "#FF0C0C0C" } } // ball circle path PathRectangle { x: 0 y: 0 width: controllerSize height: controllerSize radius: controllerSize * 0.5 // circle shape } } // light point shape ShapePath { strokeWidth: 0 // glow gradient fillGradient: RadialGradient { centerX: lightPoint.x + centerRadius centerY: lightPoint.y + centerRadius centerRadius: lightPoint.radius focalX: centerX focalY: centerY GradientStop { position: 0.4; color: "#FFFFFFFF" } GradientStop { position: 0.75; color: "#33FFFFFF" } GradientStop { position: 1.0; color: "#00FFFFFF" } } // point circle path PathRectangle { id: lightPoint readonly property double startOffset : (lightBallController.width - width) * 0.5 x: startOffset y: startOffset width: controllerRadius * 0.4 height: width radius: width * 0.5 // circle shape } } } MouseArea { id: lightMouseArea anchors.centerIn: parent anchors.fill: parent onPositionChanged: { // get coordinates from center var x = mouseX - controllerRadius var y = mouseY - controllerRadius // get distance to center var distance = Math.sqrt(x * x + y * y) // avoid controller overflow if(distance > controllerRadius) { var angleRad = Math.atan2(y, x) x = controllerRadius * Math.cos(angleRad) y = controllerRadius * Math.sin(angleRad) } // get sphere maximum x coordinate var xMax = Math.sqrt(controllerRadius * controllerRadius - y * y) // update light yaw and pitch lightYawValue = (xMax > 0) ? ((x / xMax) * 90) : 0 // between -90 and 90 degrees lightPitchValue = (y / controllerRadius) * 90 // between -90 and 90 degrees } } } } } DoubleValidator { id: doubleDegreeValidator locale: 'C' // use '.' decimal separator disregarding of the system locale bottom: -90.0 top: 90.0 } TextMetrics { id: textMetricsDegreeValue font: lightYawTF.font text: "12.3456" } } ================================================ FILE: meshroom/ui/qml/Controls/ExifOrientedViewer.qml ================================================ import QtQuick import Utils 1.0 /** * Loader with a predefined transform to orient its content according to the provided Exif tag. * Useful when displaying images and overlaid information in the Viewer2D. * * Usage: * - set the orientationTag property to specify Exif orientation tag. * - set the xOrigin/yOrigin properties to specify the transform origin. */ Loader { property var orientationTag: undefined property real xOrigin: 0 property real yOrigin: 0 transform: [ Rotation { angle: ExifOrientation.rotation(orientationTag) origin.x: xOrigin origin.y: yOrigin }, Scale { xScale: ExifOrientation.xscale(orientationTag) origin.x: xOrigin origin.y: yOrigin } ] } ================================================ FILE: meshroom/ui/qml/Controls/ExpandableGroup.qml ================================================ import QtQuick import QtQuick.Controls import QtQuick.Layouts import MaterialIcons 2.2 /** * A custom GroupBox with predefined header that can be hidden and expanded. */ GroupBox { id: root title: "" property int sidePadding: 6 property alias labelBackground: labelBg property alias toolBarContent: toolBar.data property bool expanded: expandButton.checked padding: 2 leftPadding: sidePadding rightPadding: sidePadding topPadding: label.height + padding background: Item {} MouseArea { parent: paneLabel anchors.fill: parent onClicked: function(mouse) { expandButton.checked = !expandButton.checked } } label: Pane { id: paneLabel padding: 2 width: root.width background: Rectangle { id: labelBg color: palette.base opacity: 0.8 } RowLayout { width: parent.width Label { text: root.title Layout.fillWidth: true elide: Text.ElideRight padding: 3 font.bold: true font.pointSize: 8 } RowLayout { id: toolBar height: parent.height MaterialToolButton { id: expandButton ToolTip.text: "Expand More" text: MaterialIcons.expand_more font.pointSize: 10 implicitHeight: parent.height checkable: true checked: false onCheckedChanged: { if (checked) { ToolTip.text = "Expand Less" text = MaterialIcons.expand_less } else { ToolTip.text = "Expand More" text = MaterialIcons.expand_more } } } } } } } ================================================ FILE: meshroom/ui/qml/Controls/FilterComboBox.qml ================================================ import QtQuick import QtQuick.Controls import QtQuick.Layouts import Utils 1.0 import MaterialIcons /** * ComboBox with filtering capabilities and support for custom values (i.e: outside the source model). */ ComboBox { id: root // Model to populate the combobox. required property var sourceModel // Input value to use as the current combobox value. property var inputValue // The text to filter the combobox model when the choices are displayed. property alias filterText: filterTextArea.text // Whether the current input value is within the source model. readonly property bool validValue: sourceModel.includes(inputValue) QtObject { id: m readonly property int delegateModelCount: root.delegateModel.count // Ensure the highlighted index is always within the range of delegates whenever the // combobox model changes, for combobox validation to always considers a valid item. onDelegateModelCountChanged: { if(delegateModelCount > 0 && root.highlightedIndex >= delegateModelCount) { while(root.highlightedIndex > 0 && root.highlightedIndex >= delegateModelCount) { // highlightIndex is read-only, this method has to be used to change it programmatically. root.decrementCurrentIndex(); } } } } signal editingFinished(var value) function clearFilter() { filterText = ""; } // Re-computing current index when source values are set. Component.onCompleted: _updateCurrentIndex() onInputValueChanged: _updateCurrentIndex() onModelChanged: _updateCurrentIndex() function _updateCurrentIndex() { currentIndex = find(inputValue); } displayText: inputValue model: { return sourceModel.filter(item => { return item.toString().toLowerCase().includes(filterText.toLowerCase()); }); } popup.onOpened: { filterTextArea.forceActiveFocus(); } popup.onClosed: clearFilter() onActivated: (index) => { const isValidEntry = model.length > 0; if (!isValidEntry) { return; } editingFinished(model[index]); } StateGroup { id: filterState // Override properties depending on filter text status. states: [ State { name: "Invalid" when: m.delegateModelCount === 0 PropertyChanges { target: filterTextArea color: Colors.orange // Prevent ComboBox validation when there are no entries in the model. Keys.forwardTo: [] } } ] } popup.contentItem: ColumnLayout { width: parent.width spacing: 0 RowLayout { Layout.fillWidth: true spacing: 2 TextField { id: filterTextArea placeholderText: "Type to filter..." Layout.fillWidth: true leftPadding: 18 Keys.forwardTo: [root] background: Item { MaterialLabel { anchors.verticalCenter: parent.verticalCenter anchors.left: parent.left anchors.leftMargin: 2 text: MaterialIcons.search } } } MaterialToolButton { enabled: root.filterText !== "" text: MaterialIcons.add_task ToolTip.text: "Force custom value" onClicked: { editingFinished(root.filterText); root.popup.close(); } } } Rectangle { height: 1 Layout.fillWidth: true color: Colors.sysPalette.mid } ScrollView { Layout.fillWidth: true Layout.fillHeight: true ScrollBar.horizontal.policy: ScrollBar.AlwaysOff ListView { implicitHeight: contentHeight clip: true model: root.delegateModel highlightRangeMode: ListView.ApplyRange currentIndex: root.highlightedIndex ScrollBar.vertical: ScrollBar {} } } } } ================================================ FILE: meshroom/ui/qml/Controls/FloatingPane.qml ================================================ import QtQuick import QtQuick.Controls import QtQuick.Layouts /** * FloatingPane provides a Pane with a slightly transparent default background * using palette.base as color. Useful to create floating toolbar/overlays. */ Pane { id: root property bool opaque: false property int radius: 1 padding: 6 anchors.margins: 2 background: Rectangle { color: root.palette.base opacity: opaque ? 1.0 : 0.7 radius: root.radius } } ================================================ FILE: meshroom/ui/qml/Controls/Group.qml ================================================ import QtQuick import QtQuick.Controls import QtQuick.Layouts /** * A custom GroupBox with predefined header. */ GroupBox { id: root title: "" property int sidePadding: 6 property alias labelBackground: labelBg property alias toolBarContent: toolBar.data padding: 2 leftPadding: sidePadding rightPadding: sidePadding topPadding: label.height + padding background: Item {} label: Pane { padding: 2 width: root.width background: Rectangle { id: labelBg color: palette.base opacity: 0.8 } RowLayout { width: parent.width Label { text: root.title Layout.fillWidth: true elide: Text.ElideRight padding: 3 font.bold: true font.pointSize: 8 } RowLayout { id: toolBar height: parent.height } } } } ================================================ FILE: meshroom/ui/qml/Controls/IntSelector.qml ================================================ import QtQuick import QtQuick.Controls import QtQuick.Layouts import MaterialIcons 2.2 /* * IntSelector with arrows and a text input to select a number */ Row { id: root property string tooltipText: "" property int value: 0 property var range: { "min" : 0, "max" : 0 } Layout.alignment: Qt.AlignVCenter spacing: 0 property bool displayButtons: previousIntButton.hovered || intInputMouseArea.containsMouse || nextIntButton.hovered property real buttonsOpacity: displayButtons ? 1.0 : 0.0 MaterialToolButton { id: previousIntButton opacity: buttonsOpacity width: 10 text: MaterialIcons.navigate_before ToolTip.text: "Previous" onClicked: { if (value > range.min) { value -= 1 } } } TextInput { id: intInput ToolTip.text: tooltipText ToolTip.visible: tooltipText && intInputMouseArea.containsMouse width: intMetrics.width height: previousIntButton.height color: palette.text horizontalAlignment: Text.AlignHCenter verticalAlignment: Text.AlignVCenter selectByMouse: true text: value onEditingFinished: { // We first assign the frame to the entered text even if it is an invalid frame number. We do it for extreme cases, for example without doing it, if we are at 0, and put a negative number, value would be still 0 and nothing happens but we will still see the wrong number value = parseInt(text) value = Math.min(range.max, Math.max(range.min, parseInt(text))) focus = false } MouseArea { id: intInputMouseArea anchors.fill: parent hoverEnabled: true acceptedButtons: Qt.NoButton propagateComposedEvents: true } } MaterialToolButton { id: nextIntButton width: 10 opacity: buttonsOpacity text: MaterialIcons.navigate_next ToolTip.text: "Next" onClicked: { if (value < range.max) { value += 1 } } } TextMetrics { id: intMetrics font: intInput.font text: "10000" } } ================================================ FILE: meshroom/ui/qml/Controls/KeyValue.qml ================================================ import QtQuick import QtQuick.Controls import QtQuick.Layouts /** * KeyValue allows to create a list of key/value, like a table. */ Rectangle { property alias key: keyLabel.text property alias value: valueText.text color: activePalette.window width: parent.width height: childrenRect.height RowLayout { width: parent.width Rectangle { anchors.margins: 2 color: Qt.darker(activePalette.window, 1.1) Layout.minimumWidth: 10.0 * Qt.application.font.pixelSize Layout.maximumWidth: 15.0 * Qt.application.font.pixelSize Layout.fillWidth: false Layout.fillHeight: true Label { id: keyLabel text: "test" anchors.fill: parent anchors.top: parent.top topPadding: 4 leftPadding: 6 verticalAlignment: TextEdit.AlignTop elide: Text.ElideRight } } TextArea { id: valueText text: "" anchors.margins: 2 Layout.fillWidth: true wrapMode: Label.WrapAtWordBoundaryOrAnywhere textFormat: TextEdit.PlainText readOnly: true selectByMouse: true background: Rectangle { anchors.fill: parent color: Qt.darker(activePalette.window, 1.05) } } } } ================================================ FILE: meshroom/ui/qml/Controls/MScrollBar.qml ================================================ import QtQuick import QtQuick.Controls /** * MScrollBar is a custom scrollbar implementation. * It is a vertical scrollbar that can be used to scroll a ListView. */ ScrollBar { id: root policy: ScrollBar.AlwaysOn visible: root.horizontal ? parent.contentWidth > parent.width : parent.contentHeight > parent.height minimumSize: 0.1 Component.onCompleted: { contentItem.color = Qt.lighter(palette.mid, 2) } onHoveredChanged: { if (pressed) return contentItem.color = hovered ? Qt.lighter(palette.mid, 3) : Qt.lighter(palette.mid, 2) } onPressedChanged: { contentItem.color = pressed ? Qt.lighter(palette.mid, 4) : hovered ? Qt.lighter(palette.mid, 3) : Qt.lighter(palette.mid, 2) } } ================================================ FILE: meshroom/ui/qml/Controls/MSplitView.qml ================================================ import QtQuick import QtQuick.Controls SplitView { id: splitView handle: Rectangle { id: handleDelegate implicitWidth: 5 implicitHeight: 5 color: palette.window property bool hovered: SplitHandle.hovered property bool pressed: SplitHandle.pressed Rectangle { id: handleDisplay anchors.centerIn: parent property int handleSize: handleDelegate.pressed ? 3 : 1 width: splitView.orientation === Qt.Horizontal ? handleSize : handleDelegate.width height: splitView.orientation === Qt.Vertical ? handleSize : handleDelegate.height color: handleDelegate.hovered ? palette.highlight : palette.base } } } ================================================ FILE: meshroom/ui/qml/Controls/MessageDialog.qml ================================================ import QtQuick import QtQuick.Controls import QtQuick.Layouts import MaterialIcons 2.2 Dialog { id: root property alias text: textLabel.text property alias detailedText: detailedLabel.text property alias helperText: helperLabel.text property alias icon: iconLabel property alias canCopy: copyButton.visible property alias preset: presets.state property alias content: contentComponent.sourceComponent property alias textMetrics: textMetrics default property alias children: layout.children // The content of this MessageDialog as a string readonly property string asString: titleLabel.text + "\n\n" + text + "\n" + detailedText + "\n" + helperText + "\n" /// Return the text content of this dialog as a simple string. /// Used when copying the message in the system clipboard. /// Can be overridden in components extending MessageDialog function getAsString() { return asString } x: parent.width / 2 - width / 2 y: parent.height / 2 - height / 2 modal: true padding: 15 standardButtons: Dialog.Ok header: Pane { topPadding: 6 bottomPadding: 0 leftPadding: 8 rightPadding: leftPadding background: Item { // Hidden text edit to perform copy in clipboard TextEdit { id: textContent visible: false } } RowLayout { // Icon Label { id: iconLabel font.family: MaterialIcons.fontFamily font.pointSize: 14 visible: text != "" } Label { id: titleLabel text: title + " - " + Qt.application.name + " " + Qt.application.version font.bold: true } MaterialToolButton { id: copyButton text: MaterialIcons.content_copy ToolTip.text: "Copy Message to Clipboard" font.pointSize: 11 onClicked: { textContent.text = getAsString() textContent.selectAll(); textContent.copy() } } } } contentItem: ColumnLayout { id: layout // Text spacing: 12 Label { id: textLabel font.bold: true visible: text != "" onLinkActivated: function(link) { Qt.openUrlExternally(link) } Layout.minimumWidth: 500 Layout.preferredWidth: titleLabel.width wrapMode: Text.WordWrap } // Detailed text Label { id: detailedLabel text: text visible: text != "" onLinkActivated: function(link) { Qt.openUrlExternally(link) } Layout.minimumWidth: 500 Layout.preferredWidth: titleLabel.width wrapMode: Text.WordWrap } // Additional helper text Label { id: helperLabel visible: text != "" onLinkActivated: function(link) { Qt.openUrlExternally(link) } Layout.minimumWidth: 500 Layout.preferredWidth: titleLabel.width wrapMode: Text.WordWrap } Loader { id: contentComponent Layout.fillWidth: true } } TextMetrics { id: textMetrics text: "A" } StateGroup { id: presets states: [ State { name: "Info" PropertyChanges { target: iconLabel text: MaterialIcons.info } }, State { name: "Warning" PropertyChanges { target: iconLabel text: MaterialIcons.warning color: "#FF9800" } }, State { name: "Error" PropertyChanges { target: iconLabel text: MaterialIcons.error color: "#F44336" } } ] } } ================================================ FILE: meshroom/ui/qml/Controls/NodeActions.qml ================================================ import QtQuick import QtQuick.Controls import QtQuick.Layouts import MaterialIcons 2.2 import Utils 1.0 Item { id: root // Settings readonly property real headerOffset: 10 // Distance above the node in screen pixels readonly property real _opacity: 0.9 // Objects passed from the graph editor property var uigraph: null property var draggable: null // The draggable container from GraphEditor property var nodeRepeater: null // Reference to nodeRepeater to find delegates // Signals signal computeRequest(var node) // Start local computation signal stopComputeRequest(var node) // Stop local computation signal deleteDataRequest(var node) // Delete node data signal submitRequest(var node) // Start external computation (submission on farm) signal stopSubmitRequest(var node) // Stop external computation (interrupt tasks on farm) signal retrySubmitRequest(var node) // Retry error tasks on farm SystemPalette { id: activePalette } /** * Get the node delegate */ function nodeDelegate(node) { if (!nodeRepeater) return null for (var i = 0; i < nodeRepeater.count; ++i) { if (nodeRepeater.getItemAt(i).node === node) return nodeRepeater.getItemAt(i) } return null } enum ButtonState { DISABLED = 0, LAUNCHABLE = 1, DELETABLE = 2, STOPPABLE = 3 } Rectangle { id: actionHeader readonly property bool hasSelectedNode: uigraph && uigraph.nodeSelection.selectedIndexes.length === 1 readonly property var selectedNode: hasSelectedNode ? uigraph.selectedNode : null readonly property var selectedNodeDelegate: selectedNode ? root.nodeDelegate(selectedNode) : null visible: selectedNodeDelegate !== null color: "transparent" width: actionItemsRow.width height: actionItemsRow.height // // ===== Manage NodeActions position ===== // // Prevents losing focus on the node when we click on buttons of the actionItems MouseArea { anchors.fill: parent onPressed: function(mouse) { mouse.accepted = true } onReleased: function(mouse) { mouse.accepted = true } onClicked: function(mouse) { mouse.accepted = true } onDoubleClicked: function(mouse) { mouse.accepted = true } hoverEnabled: false } function keepNodeActionOnWindow() { if (x < 0) { x = 0 } if (y < 0) { y = 0 } } // Update position function updatePosition() { if (width == 0 && height == 0) { actionItemsRow.visible = true return } else if (width == 0 || height == 0) { actionItemsRow.visible = false return } actionItemsRow.visible = true if (!selectedNodeDelegate || !draggable) return // Calculate node position in screen coordinates const nodeScreenX = selectedNodeDelegate.x * draggable.scale + draggable.x const nodeScreenY = selectedNodeDelegate.y * draggable.scale + draggable.y // Position header above the node (fixed offset in screen pixels) x = nodeScreenX + (selectedNodeDelegate.width * draggable.scale - width) / 2 y = nodeScreenY - height - headerOffset // keepNodeActionOnWindow() } onHeightChanged: { actionHeader.updatePosition() } onWidthChanged: { actionHeader.updatePosition() } // Update position when the user moves on the graph Connections { target: root.draggable function onXChanged() { Qt.callLater(actionHeader.updatePosition) } function onYChanged() { Qt.callLater(actionHeader.updatePosition) } function onScaleChanged() { Qt.callLater(actionHeader.updatePosition) } } // Update position when nodes are moved Connections { target: actionHeader.selectedNodeDelegate function onXChanged() { actionHeader.updatePosition() } function onYChanged() { actionHeader.updatePosition() } ignoreUnknownSignals: true } // // ===== Manage buttons ===== // property bool nodeIsLocked: false property bool canComputeNode: false property bool canStopNode: false property bool canRestartNode: false // Node can be restarted, locally or externally property bool canSubmitNode: false property bool nodeSubmitted: false property bool canRetryNode: false // Error tasks can be restarted for external node property int computeButtonState: NodeActions.ButtonState.LAUNCHABLE property string computeButtonIcon: { switch (computeButtonState) { case NodeActions.ButtonState.STOPPABLE: return MaterialIcons.cancel_schedule_send default: return MaterialIcons.send } } property string computeButtonTooltip: { switch (computeButtonState) { case NodeActions.ButtonState.STOPPABLE: return "Stop Compute" default: return "Start Compute" } } property int submitButtonState: NodeActions.ButtonState.LAUNCHABLE property string submitButtonIcon: { switch (submitButtonState) { case NodeActions.ButtonState.STOPPABLE: return MaterialIcons.paragliding default: return MaterialIcons.rocket_launch } } property string submitButtonTooltip: { switch (submitButtonState) { case NodeActions.ButtonState.STOPPABLE: return "Interrupt Job on Render Farm" default: return "Submit on Render Farm" } } function getComputeButtonState(node) { if (actionHeader.canStopNode) return NodeActions.ButtonState.STOPPABLE if (!actionHeader.nodeIsLocked && node.globalStatus == "SUCCESS") return NodeActions.ButtonState.DELETABLE if (actionHeader.canComputeNode) return NodeActions.ButtonState.LAUNCHABLE return NodeActions.ButtonState.DISABLED } function getSubmitButtonState(node) { if (actionHeader.canStopNode) return NodeActions.ButtonState.STOPPABLE if (!actionHeader.nodeIsLocked && node.globalStatus == "SUCCESS") return NodeActions.ButtonState.DELETABLE if (actionHeader.canSubmitNode) return NodeActions.ButtonState.LAUNCHABLE return NodeActions.ButtonState.DISABLED } function isSubmittedExternally(node) { return node.globalExecMode == "EXTERN" && ["RUNNING", "SUBMITTED"].includes(node.globalStatus) } function isNodeRestartable(node) { return actionHeader.computeButtonState == NodeActions.ButtonState.LAUNCHABLE && ["ERROR", "STOPPED", "KILLED"].includes(node.globalStatus) } function isNodeRetriable(node) { return node.globalExecMode == "EXTERN" && ["ERROR", "STOPPED", "KILLED"].includes(node.globalStatus) } function updateProperties(node) { if (!node) return // Update properties values actionHeader.canComputeNode = uigraph.canComputeNode(node) actionHeader.canSubmitNode = uigraph.canSubmitNode(node) actionHeader.canStopNode = node.canBeStopped() || node.canBeCanceled() actionHeader.nodeIsLocked = node.locked actionHeader.nodeSubmitted = isSubmittedExternally(node) // Update button states actionHeader.computeButtonState = getComputeButtonState(node) actionHeader.submitButtonState = getSubmitButtonState(node) actionHeader.canRestartNode = isNodeRestartable(node) actionHeader.canRetryNode = isNodeRetriable(node) } // Set initial state & position onSelectedNodeDelegateChanged: { if (actionHeader.selectedNode) { actionHeader.updateProperties(actionHeader.selectedNode) Qt.callLater(actionHeader.updatePosition) } } // Listen to updates to status Connections { target: actionHeader.selectedNode function onGlobalStatusChanged() { actionHeader.updateProperties(target) } function onLockedChanged() { actionHeader.nodeIsLocked = target.locked } ignoreUnknownSignals: true } // Listen to updates from nodes that are not selected Connections { target: root.uigraph function onComputingChanged() { actionHeader.updateProperties(actionHeader.selectedNode) } ignoreUnknownSignals: true } Row { id: actionItemsRow anchors.centerIn: parent spacing: 2 // Compute button MaterialToolButton { id: computeButton font.pointSize: 16 text: actionHeader.computeButtonIcon padding: 6 ToolTip.text: actionHeader.computeButtonTooltip ToolTip.visible: hovered ToolTip.delay: 1000 visible: actionHeader.computeButtonState != NodeActions.ButtonState.DISABLED enabled: visible && !actionHeader.nodeSubmitted // Launchable & Stoppable, local // Icon color textColor: checked ? palette.highlight : palette.text // Background color background: Rectangle { color: { if (!computeButton.enabled) return activePalette.button if (actionHeader.computeButtonState == NodeActions.ButtonState.STOPPABLE) return computeButton.hovered ? Colors.orange : Qt.darker(Colors.orange, 1.3) return computeButton.hovered ? activePalette.highlight : activePalette.button } opacity: computeButton.hovered ? 1 : root._opacity border.color: computeButton.hovered ? activePalette.highlight : Qt.darker(activePalette.window, 1.3) border.width: 1 radius: 3 } onClicked: { switch (actionHeader.computeButtonState) { case NodeActions.ButtonState.STOPPABLE: root.stopComputeRequest(actionHeader.selectedNode) break case NodeActions.ButtonState.LAUNCHABLE: root.computeRequest(actionHeader.selectedNode) break case NodeActions.ButtonState.DELETABLE: root.deleteDataRequest(actionHeader.selectedNode) root.computeRequest(actionHeader.selectedNode) break default: break } } } // Clear node MaterialToolButton { id: deleteDataButton font.pointSize: 16 text: MaterialIcons.delete_ padding: 6 ToolTip.text: "Delete Data" ToolTip.visible: hovered ToolTip.delay: 1000 visible: actionHeader.canRestartNode || actionHeader.computeButtonState == NodeActions.ButtonState.DELETABLE enabled: visible background: Rectangle { color: computeButton.hovered ? Colors.red : Qt.darker(Colors.red, 1.3) opacity: computeButton.hovered ? 1 : root._opacity border.color: computeButton.hovered ? activePalette.highlight : Qt.darker(activePalette.window, 1.3) border.width: 1 radius: 3 } onClicked: { root.deleteDataRequest(actionHeader.selectedNode) } } // Submit button MaterialToolButton { id: submitButton font.pointSize: 16 text: actionHeader.submitButtonIcon padding: 6 ToolTip.text: actionHeader.submitButtonTooltip ToolTip.visible: hovered ToolTip.delay: 1000 visible: actionHeader.submitButtonState != NodeActions.ButtonState.DISABLED enabled: visible && (actionHeader.nodeSubmitted || !actionHeader.nodeIsLocked) // Launchable & Stoppable, external // Icon color textColor: checked ? palette.highlight : palette.text // Background color background: Rectangle { color: { if (!submitButton.enabled) return activePalette.button if (actionHeader.submitButtonState == NodeActions.ButtonState.STOPPABLE) return submitButton.hovered ? Colors.orange : Qt.darker(Colors.orange, 1.3) return submitButton.hovered ? activePalette.highlight : activePalette.button } opacity: submitButton.hovered ? 1 : root._opacity border.color: submitButton.hovered ? activePalette.highlight : Qt.darker(activePalette.window, 1.3) border.width: 1 radius: 3 } onClicked: { switch (actionHeader.submitButtonState) { case NodeActions.ButtonState.STOPPABLE: root.stopSubmitRequest(actionHeader.selectedNode) break case NodeActions.ButtonState.LAUNCHABLE: root.submitRequest(actionHeader.selectedNode) actionHeader.updateProperties(actionHeader.selectedNode) break case NodeActions.ButtonState.DELETABLE: root.deleteDataRequest(actionHeader.selectedNode) root.submitRequest(actionHeader.selectedNode) break default: break } } } // Retry button (for farm submissions that have failed) MaterialToolButton { id: retryButton font.pointSize: 16 text: MaterialIcons.cloud_sync padding: 6 ToolTip.text: "Retry Submission On Render Farm" ToolTip.visible: hovered ToolTip.delay: 1000 visible: actionHeader.canRetryNode enabled: visible // Background color background: Rectangle { color: { return retryButton.hovered ? activePalette.highlight : activePalette.button } opacity: retryButton.hovered ? 1 : root._opacity border.color: retryButton.hovered ? activePalette.highlight : Qt.darker(activePalette.window, 1.3) border.width: 1 radius: 3 } onClicked: { root.retrySubmitRequest(actionHeader.selectedNode) } } } } } ================================================ FILE: meshroom/ui/qml/Controls/Panel.qml ================================================ import QtQuick import QtQuick.Controls import QtQuick.Layouts /** * Panel is a container control with preconfigured header/footer. * * The header displays an optional icon and the title of the Panel, * and provides a placeholder (headerBar) at the top right corner, useful to create a contextual toolbar. * * * The footer is empty (and not visible) by default. It does not provided any layout. */ Page { id: root property alias headerBar: headerLayout.data property Component titleComponent: null // Allow custom component for title property alias footerContent: footerLayout.data property alias icon: iconPlaceHolder.data property alias loading: loadingIndicator.running property alias loadingText: loadingLabel.text clip: true QtObject { id: m property int hPadding: 6 property int vPadding: 4 readonly property color paneBackgroundColor: Qt.darker(root.palette.window, 1.15) } padding: 1 header: Pane { id: headerPane topPadding: m.vPadding; bottomPadding: m.vPadding leftPadding: m.hPadding; rightPadding: m.hPadding background: Item { Rectangle { anchors.fill: parent color: m.paneBackgroundColor } MouseArea { anchors.fill: parent onPressed: { headerLayout.forceActiveFocus() } } } RowLayout { width: parent.width // Icon Item { id: iconPlaceHolder width: childrenRect.width height: childrenRect.height Layout.alignment: Qt.AlignVCenter visible: icon !== "" } // Title // Either we load the custom root.titleComponent or we just put the root.title Loader { id: titleLoader sourceComponent: root.titleComponent !== null ? root.titleComponent : defaultTitleComponent Layout.fillWidth: false } Component { id: defaultTitleComponent Label { text: root.title elide: Text.ElideRight topPadding: m.vPadding bottomPadding: m.vPadding } } Item { width: 10 } // Feature loading status BusyIndicator { id: loadingIndicator padding: 0 implicitWidth: 12 implicitHeight: 12 running: false } Label { id: loadingLabel text: "" font.italic: true } Item { Layout.fillWidth: true } // Header menu Row { id: headerLayout } } } footer: Pane { id: footerPane topPadding: m.vPadding; bottomPadding: m.vPadding leftPadding: m.hPadding; rightPadding: m.hPadding visible: footerLayout.children.length > 0 background: Item { Rectangle { anchors.fill: parent color: m.paneBackgroundColor } MouseArea { anchors.fill: parent onPressed: { footerLayout.forceActiveFocus() } } } // Content place holder RowLayout { id: footerLayout width: parent.width } } } ================================================ FILE: meshroom/ui/qml/Controls/SearchBar.qml ================================================ import QtQuick import QtQuick.Controls import QtQuick.Layouts import MaterialIcons 2.2 /** * Basic SearchBar component with an appropriate icon and a TextField. */ FocusScope { id: root property alias textField: field property alias text: field.text // Enables hiding and showing of the text field on Search button click property bool toggle: false property bool isVisible: false // Size properties property int maxWidth: 150 property int minWidth: 30 // The default width is computed based on whether toggling is enabled and if the visibility is true width: toggle && isVisible ? maxWidth : minWidth // Keyboard interaction related signals signal accepted() implicitHeight: childrenRect.height Keys.forwardTo: [field] function forceActiveFocus() { root.isVisible = true field.forceActiveFocus() } function clear() { field.clear() } RowLayout { spacing: 0 width: parent.width MaterialToolButton { text: MaterialIcons.search onClicked: { root.isVisible = !root.isVisible // Set Focus on the Text Field field.focus = field.visible } } TextField { id: field focus: true Layout.fillWidth: true selectByMouse: true rightPadding: clear.width // The text field is visible either when toggle is not activated or the visible property is set visible: root.toggle ? root.isVisible : true // Ensure the field has focus when the text is modified onTextChanged: { forceActiveFocus() } // Handle enter Key press and forward it to the parent Keys.onPressed: (event)=> { if ((event.key == Qt.Key_Return || event.key == Qt.Key_Enter)) { event.accepted = true root.accepted() } else if (event.key == Qt.Key_Escape) { root.isVisible = false field.focus = false } } MaterialToolButton { id: clear // Anchors anchors.right: parent.right anchors.rightMargin: 2 // Leave a tiny bit of space so that its highlight does not overlap with the boundary of the parent anchors.verticalCenter: parent.verticalCenter // Style font.pointSize: 8 text: MaterialIcons.close ToolTip.text: "Clears text." // States visible: field.text // Signals -> Slots onClicked: { field.text = "" parent.focus = true } } } } } ================================================ FILE: meshroom/ui/qml/Controls/SelectionBox.qml ================================================ import QtQuick /* Simple selection box that can be used by a MouseArea. Usage: 1. Create a MouseArea and a SelectionBox. 2. Bind the SelectionBox to the MouseArea by setting the `mouseArea` property. 3. Call startSelection() with coordinates when the selection starts. 4. Call endSelection() when the selection ends. 5. Listen to the selectionEnded signal to get the selection rectangle. */ Item { id: root property MouseArea mouseArea property alias color: selectionBox.color property alias border: selectionBox.border readonly property bool active: mouseArea.drag.target == dragTarget signal selectionEnded(rect selectionRect, int modifiers) function startSelection(mouse) { dragTarget.startPos.x = dragTarget.x = mouse.x; dragTarget.startPos.y = dragTarget.y = mouse.y; dragTarget.modifiers = mouse.modifiers; mouseArea.drag.target = dragTarget; } function endSelection() { if (!active) { return; } mouseArea.drag.target = null; const rect = Qt.rect(selectionBox.x, selectionBox.y, selectionBox.width, selectionBox.height) selectionEnded(rect, dragTarget.modifiers); } visible: active Rectangle { id: selectionBox color: "#109b9b9b" border.width: 1 border.color: "#b4b4b4" x: Math.min(dragTarget.startPos.x, dragTarget.x) y: Math.min(dragTarget.startPos.y, dragTarget.y) width: Math.abs(dragTarget.x - dragTarget.startPos.x) height: Math.abs(dragTarget.y - dragTarget.startPos.y) } Item { id: dragTarget property point startPos property var modifiers } } ================================================ FILE: meshroom/ui/qml/Controls/SelectionLine.qml ================================================ import QtQuick import QtQuick.Shapes /* Simple selection line that can be used by a MouseArea. Usage: 1. Create a MouseArea and a selectionShape. 2. Bind the selectionShape to the MouseArea by setting the `mouseArea` property. 3. Call startSelection() with coordinates when the selection starts. 4. Call endSelection() when the selection ends. 5. Listen to the selectionEnded signal to get the segment (defined by 2 points). */ Item { id: root property MouseArea mouseArea readonly property bool active: mouseArea.drag.target == dragTarget signal selectionEnded(point selectionP1, point selectionP2, int modifiers) function startSelection(mouse) { dragTarget.startPos.x = dragTarget.x = mouse.x; dragTarget.startPos.y = dragTarget.y = mouse.y; dragTarget.modifiers = mouse.modifiers; mouseArea.drag.target = dragTarget; } function endSelection() { if (!active) { return; } mouseArea.drag.target = null; const p1 = Qt.point(selectionShape.x, selectionShape.y); const p2 = Qt.point(selectionShape.x + selectionShape.width, selectionShape.y + selectionShape.height); selectionEnded(p1, p2, dragTarget.modifiers); } visible: active Item { id: selectionShape x: dragTarget.startPos.x y: dragTarget.startPos.y width: dragTarget.x - dragTarget.startPos.x height: dragTarget.y - dragTarget.startPos.y Shape { id: dynamicLine; width: selectionShape.width; height: selectionShape.height; anchors.fill: parent; ShapePath { strokeWidth: 2; strokeStyle: ShapePath.DashLine; strokeColor: "#CC3E3E"; dashPattern: [3, 2]; startX: 0; startY: 0; PathLine { x: selectionShape.width; y: selectionShape.height; } } } } Item { id: dragTarget property point startPos property var modifiers } } ================================================ FILE: meshroom/ui/qml/Controls/StatusBar.qml ================================================ import QtQuick import QtQuick.Controls import QtQuick.Layouts import MaterialIcons 2.2 import Utils 1.0 RowLayout { id: root property color defaultColor: Qt.darker(palette.text, 1.2) property string defaultIcon : MaterialIcons.circle property int interval : 5000 property bool logMessage : false TextField { id: statusBarField Layout.fillHeight: true readOnly: true selectByMouse: true text: statusBar.message color: defaultColor background: Item {} visible: statusBar.message !== "" } // TODO : Idea for later : implement a ProgressBar here MaterialToolButton { id: statusBarButton Layout.fillHeight: true Layout.preferredWidth: 17 visible: true font.pointSize: 8 text: defaultIcon ToolTip.text: "Open Messages UI" onClicked: { var component = Qt.createComponent("StatusMessages.qml") var window = component.createObject(root) window.show() } Component.onCompleted: { statusBarButton.contentItem.color = defaultColor } } Timer { id: statusBarTimer interval: root.interval running: false repeat: false onTriggered: { // Erase message and reset button icon statusBar.message = "" statusBarField.color = defaultColor statusBarButton.contentItem.color = defaultColor statusBarButton.text = defaultIcon } } QtObject { id: statusBar property string message: "" function showMessage(msg, status=undefined, duration=root.interval) { var textColor = defaultColor var logLevel = "info" switch (status) { case "ok": { statusBarField.color = Colors.green statusBarButton.text = MaterialIcons.check_circle break } case "warning": { logLevel = "warn" statusBarField.color = Colors.orange statusBarButton.text = MaterialIcons.warning break } case "error": { logLevel = "error" statusBarField.color = Colors.red statusBarButton.text = MaterialIcons.error break } default: { statusBarButton.text = defaultIcon } } if (logMessage === true) { console.log("[Message][" + logLevel.toUpperCase().padEnd(5) + "] " + msg) } statusBarButton.contentItem.color = statusBarField.color statusBar.message = msg statusBarTimer.interval = duration statusBarTimer.restart() MeshroomApp.forceUIUpdate() } } function showMessage(msg, status=undefined, duration=root.interval) { statusBar.showMessage(msg, status, duration) // Add message to the message list _messageController.storeMessage(msg, status) } Connections { target: _messageController function onMessage(message, color, duration) { root.showMessage(message, color, duration) } } } ================================================ FILE: meshroom/ui/qml/Controls/StatusMessages.qml ================================================ import QtQuick import QtQuick.Controls import QtQuick.Layouts import MaterialIcons 2.2 import Utils 1.0 ApplicationWindow { id: root title: "Messages" width: 500 height: 400 minimumWidth: 350 minimumHeight: 250 SystemPalette { id: systemPalette } function getColor(status) { switch (status) { case "ok": return Colors.green case "warning": return Colors.orange case "error": return Colors.red default: return systemPalette.text } } function getBackgroundColor(status) { var color = getColor(status) var alphaValue = status == "info" ? 0.05 : 0.1 return Qt.rgba(color.r, color.g, color.b, alphaValue) } function getBorderColor(status) { var color = getColor(status) var alphaValue = status == "info" ? 0.2 : 0.3 return Qt.rgba(color.r, color.g, color.b, alphaValue) } function getStatusIcon(status) { switch (status) { case "ok": return MaterialIcons.check_circle case "warning": return MaterialIcons.warning case "error": return MaterialIcons.error default: return MaterialIcons.info } } header: ToolBar { background: Rectangle { implicitWidth: root.width implicitHeight: 50 color: Qt.darker(systemPalette.base, 1.2) } RowLayout { anchors.fill: parent Text { Layout.fillWidth: true text: "Messages (" + messageListView.count + ")" font.bold: true color: Qt.darker(systemPalette.text, 1.2) } MaterialToolButton { ToolTip.text: "Clear the message list" text: MaterialIcons.clear_all font.pointSize: 16 palette.base: systemPalette.base // Text color Component.onCompleted: { contentItem.color = Qt.darker(systemPalette.text, 1.2) } onClicked: _messageController.clearMessages() } MaterialToolButton { ToolTip.text: "Copy the messages" text: MaterialIcons.content_copy font.pointSize: 16 palette.base: systemPalette.base // Text color Component.onCompleted: { contentItem.color = Qt.darker(systemPalette.text, 1.2) } onClicked: { var msgDict = _messageController.getMessagesAsString() if (msgDict !== '') { Clipboard.clear() Clipboard.setText(msgDict) } } } } } Rectangle { anchors.fill: parent color: systemPalette.base ScrollView { anchors.fill: parent anchors.margins: 10 ListView { id: messageListView model: _messageController.messages verticalLayoutDirection: ListView.TopToBottom spacing: 5 delegate: Rectangle { width: messageListView.width height: messageLayout.implicitHeight + 16 color: root.getBackgroundColor(modelData.status) border.color: root.getBorderColor(modelData.status) border.width: 1 radius: 4 RowLayout { id: messageLayout anchors.fill: parent anchors.margins: 8 spacing: 12 // Icon Text { text: root.getStatusIcon(modelData.status) font.pointSize: 14 color: root.getColor(modelData.status) Layout.alignment: Qt.AlignVCenter } // Text RowLayout { Layout.fillWidth: true spacing: 8 Text { text: modelData.date font.pointSize: 8 color: Qt.darker(systemPalette.windowText, 1.5) Layout.alignment: Qt.AlignLeft } Text { text: modelData.text wrapMode: Text.WordWrap Layout.fillWidth: true color: systemPalette.windowText font.pointSize: 10 } } } } // Empty state Text { anchors.centerIn: parent text: "No message to display" color: Qt.darker(systemPalette.windowText, 1.5) font.pointSize: 12 visible: messageListView.count === 0 } } } } } ================================================ FILE: meshroom/ui/qml/Controls/TabPanel.qml ================================================ import QtQuick import QtQuick.Controls import QtQuick.Layouts Page { id: root property alias headerBar: headerLayout.data property alias footerContent: footerLayout.data property var tabs: [] property int currentTab: 0 onCurrentTabChanged: if (mainTabBar.currentIndex !== currentTab) mainTabBar.currentIndex = currentTab clip: true QtObject { id: m readonly property color paneBackgroundColor: Qt.darker(root.palette.window, 1.15) } padding: 0 header: Pane { id: headerPane padding: 0 background: Rectangle { color: m.paneBackgroundColor } RowLayout { width: parent.width spacing: 0 TabBar { id: mainTabBar padding: 4 Layout.fillWidth: true onCurrentIndexChanged: root.currentTab = currentIndex Repeater { model: root.tabs TabButton { text: modelData y: mainTabBar.padding padding: 4 width: text.length * font.pointSize background: Rectangle { color: index === mainTabBar.currentIndex ? root.palette.window : Qt.darker(root.palette.window, 1.30) } Rectangle { property bool commonBorder: false property int lBorderwidth: index === mainTabBar.currentIndex ? 2 : 1 property int rBorderwidth: index === mainTabBar.currentIndex ? 2 : 1 property int tBorderwidth: index === mainTabBar.currentIndex ? 2 : 1 property int bBorderwidth: 0 property int commonBorderWidth: 1 z: -1 color: Qt.darker(root.palette.window, 1.50) anchors { left: parent.left right: parent.right top: parent.top bottom: parent.bottom topMargin: commonBorder ? -commonBorderWidth : -tBorderwidth bottomMargin: commonBorder ? -commonBorderWidth : -bBorderwidth leftMargin: commonBorder ? -commonBorderWidth : -lBorderwidth rightMargin: commonBorder ? -commonBorderWidth : -rBorderwidth } } } } } Row { id: headerLayout } } } footer: Pane { id: footerPane visible: footerLayout.children.length > 0 background: Rectangle { color: m.paneBackgroundColor } RowLayout { id: footerLayout width: parent.width } } } ================================================ FILE: meshroom/ui/qml/Controls/TextFileViewer.qml ================================================ import QtQuick import QtQuick.Controls import QtQuick.Layouts import MaterialIcons 2.2 import Utils 1.0 import DataObjects 1.0 /** * Text file viewer with auto-reload feature. * Uses a ListView with one delegate by line instead of a TextArea for performance reasons. */ Item { id: root /// Source text file to load property url source /// Whether to periodically reload the source file property bool autoReload: false /// Interval (in ms) at which source file should be reloaded if autoReload is enabled property int autoReloadInterval: 2000 /// Whether the source is currently being loaded property bool loading: false /// Whether a large file warning is being displayed (file > 500 MB) property bool largeFileWarning: false /// File size in MB when a large file warning is displayed property real largeFileSizeMB: 0 /// Human-readable file size string for the large file warning readonly property string largeFileSizeStr: Format.GB2SizeStr(largeFileSizeMB / 1024) /// Whether the user confirmed loading the current large source file property bool confirmLargeLoad: false onSourceChanged: { confirmLargeLoad = false loadSource() } onAutoReloadChanged: loadSource() onVisibleChanged: if (visible) loadSource() RowLayout { anchors.fill: parent spacing: 0 // Toolbar Pane { Layout.alignment: Qt.AlignTop Layout.fillHeight: true padding: 0 background: Rectangle { color: Qt.darker(Colors.sysPalette.window, 1.2) } Column { height: parent.height spacing: 1 MaterialToolButton { text: MaterialIcons.refresh ToolTip.text: "Reload" onClicked: loadSource() } MaterialToolButton { text: MaterialIcons.vertical_align_top ToolTip.text: "Scroll to Top" onClicked: textView.positionViewAtBeginning() } MaterialToolButton { id: autoscroll text: MaterialIcons.vertical_align_bottom ToolTip.text: "Scroll to Bottom" onClicked: textView.positionViewAtEnd() checkable: false checked: textView.atYEnd } MaterialToolButton { text: MaterialIcons.assignment ToolTip.text: "Copy" onClicked: copySubMenu.open() Menu { id: copySubMenu x: parent.width MenuItem { text: "Copy Visible Text" onTriggered: { var t = "" for (var i = textView.firstVisibleIndex(); i < textView.lastVisibleIndex(); ++i) t += textView.model.get(i).line + "\n" Clipboard.setText(t) } } MenuItem { text: "Copy All" onTriggered: { Clipboard.setText(textView.text) } } } } MaterialToolButton { text: MaterialIcons.open_in_new ToolTip.text: "Open Externally" enabled: root.source !== "" onClicked: Qt.openUrlExternally(root.source) } } } MouseArea { Layout.fillWidth: true Layout.fillHeight: true Layout.margins: 4 ListView { id: textView property string text LogLinesModel { id: logLinesModel } onTextChanged: { logLinesModel.setText(text); } model: logLinesModel visible: text != "" anchors.fill: parent clip: true focus: true // Custom key navigation handling keyNavigationEnabled: false highlightFollowsCurrentItem: true highlightMoveDuration: 0 Keys.onPressed: function(event) { switch (event.key) { case Qt.Key_Home: textView.positionViewAtBeginning() break case Qt.Key_End: textView.positionViewAtEnd() break case Qt.Key_Up: currentIndex = firstVisibleIndex() decrementCurrentIndex() break; case Qt.Key_Down: currentIndex = lastVisibleIndex() incrementCurrentIndex() break; case Qt.Key_PageUp: textView.positionViewAtIndex(firstVisibleIndex(), ListView.End) break case Qt.Key_PageDown: textView.positionViewAtIndex(lastVisibleIndex(), ListView.Beginning) break } } function setText(value) { // Store current first index var topIndex = firstVisibleIndex() // Store whether autoscroll to bottom is active var scrollToBottom = atYEnd && autoscroll.checked // Replace text text = value // Restore content position by either: // - autoscrolling to bottom if (scrollToBottom) positionViewAtEnd() // - setting first visible index back (when possible) else if (topIndex !== firstVisibleIndex()) positionViewAtIndex(Math.min(topIndex, count - 1), ListView.Beginning) } function firstVisibleIndex() { return indexAt(contentX, contentY) } function lastVisibleIndex() { return indexAt(contentX, contentY + height - 2) } ScrollBar.vertical: MScrollBar { id: vScrollBar } ScrollBar.horizontal: MScrollBar {} // TextMetrics for line numbers column TextMetrics { id: lineMetrics font.family: "Monospace, Consolas, Monaco" text: textView.count * 10 } // TextMetrics for textual progress bar TextMetrics { id: progressMetrics // Total number of character in textual progress bar property int count: 51 property string character: '*' text: character.repeat(count) } delegate: RowLayout { width: textView.width spacing: 6 property var logLine: { var entry = textView.model.get(index) if (entry) { return entry } return { "line": "", "duration": -1, "time": "00:00:00", "level": LogLevelEnum.INFO } } Item { Layout.minimumWidth: childrenRect.width Layout.fillHeight: true RowLayout { height: parent.height // Colored marker to quickly indicate duration Rectangle { width: 4 Layout.fillHeight: true color: Colors.durationColor(logLine.duration) } // Line number Label { text: index + 1 Layout.minimumWidth: lineMetrics.width rightPadding: 6 Layout.fillHeight: true horizontalAlignment: Text.AlignRight color: "#CCCCCC" } } // Display a tooltip with the duration when hovered MouseArea { id: mouseArea hoverEnabled: true anchors.fill: parent } enabled: logLine.duration > 0 ToolTip.text: "Elapsed time: " + Format.sec2timeStr(logLine.duration) + "\nTime: " + (logLine.duration >= 0 ? logLine.time : "Unknown") ToolTip.visible: mouseArea.containsMouse && logLine.duration >= 0 } Loader { id: delegateLoader Layout.fillWidth: true // Default line delegate sourceComponent: line_component // Line delegate selector based on content StateGroup { states: [ State { name: "progressBar" // Detect textual progressbar (non-empty line with only progressbar character) when: logLine.line.trim().length && logLine.line.split(progressMetrics.character).length - 1 === logLine.line.trim().length PropertyChanges { target: delegateLoader sourceComponent: progressBar_component } } ] } // ProgressBar delegate Component { id: progressBar_component Item { Layout.fillWidth: true implicitHeight: progressMetrics.height ProgressBar { width: progressMetrics.width height: parent.height - 2 anchors.verticalCenter: parent.verticalCenter from: 0 to: progressMetrics.count value: logLine.line.length } } } // Default line delegate Component { id: line_component TextInput { wrapMode: Text.WrapAnywhere text: logLine.line font.family: "Monospace, Consolas, Monaco" padding: 0 selectByMouse: true readOnly: true selectionColor: Colors.sysPalette.highlight persistentSelection: false Keys.forwardTo: [textView] color: { // Color line according to log level switch (logLine.level) { case LogLevelEnum.TRACE: return Qt.darker(palette.text, 2) case LogLevelEnum.DEBUG: return Qt.darker(palette.text, 1.5) case LogLevelEnum.WARNING: return Colors.orange case LogLevelEnum.ERROR: return Colors.red case LogLevelEnum.FATAL: case LogLevelEnum.CRITICAL: return Colors.firebrick default: return palette.text } } } } } } } RowLayout { anchors.fill: parent anchors.rightMargin: vScrollBar.width z: -1 Item { Layout.preferredWidth: lineMetrics.width Layout.fillHeight: true } // IBeamCursor shape overlay MouseArea { Layout.fillWidth: true Layout.fillHeight: true cursorShape: Qt.IBeamCursor } } // File loading indicator BusyIndicator { Component.onCompleted: running = Qt.binding(function() { return root.loading }) padding: 0 anchors.right: parent.right anchors.bottom: parent.bottom implicitWidth: 16 implicitHeight: 16 } // Large file warning overlay ColumnLayout { visible: root.largeFileWarning anchors.centerIn: parent spacing: 8 Label { Layout.alignment: Qt.AlignHCenter font.family: MaterialIcons.fontFamily font.pointSize: 24 text: MaterialIcons.warning color: Colors.orange } Label { Layout.alignment: Qt.AlignHCenter font.bold: true text: "File size exceeds 500 MB" } Label { Layout.alignment: Qt.AlignHCenter text: "File size: " + root.largeFileSizeStr } Label { Layout.alignment: Qt.AlignHCenter text: "Loading this file may take a while and freeze the interface." } Button { Layout.alignment: Qt.AlignHCenter text: "Load File (" + root.largeFileSizeStr + ")" onClicked: { root.confirmLargeLoad = true root.largeFileWarning = false root._performLoad() } } } } } // Auto-reload current file timer Timer { id: reloadTimer running: root.autoReload interval: root.autoReloadInterval repeat: false // timer is restarted in request's callback (see loadSource) onTriggered: loadSource() } // Load current source file and update ListView's model function loadSource() { if (!visible) return // Check file size before loading (unless user already confirmed for this source) if (!confirmLargeLoad) { var fSizeMB = Filepath.fileSizeMB(root.source) if (fSizeMB > 500) { textView.setText("") largeFileSizeMB = fSizeMB largeFileWarning = true return } } largeFileWarning = false _performLoad() } // Internal function that performs the actual XHR file load, bypassing the size check function _performLoad() { loading = true var xhr = new XMLHttpRequest xhr.open("GET", root.source) xhr.onreadystatechange = function() { // - cannot rely on 'Last-Modified' header response to verify // that file has changed on disk (not always up-to-date) // - instead, let QML engine evaluate whether 'text' property value has changed if (xhr.readyState === XMLHttpRequest.DONE) { textView.setText(xhr.status === 200 ? xhr.responseText : "") loading = false // Re-trigger reload source file if (autoReload) reloadTimer.restart() } } xhr.send() } } ================================================ FILE: meshroom/ui/qml/Controls/qmldir ================================================ module Controls ColorChart 1.0 ColorChart.qml ColorSelector 1.0 ColorSelector.qml ExpandableGroup 1.0 ExpandableGroup.qml FloatingPane 1.0 FloatingPane.qml Group 1.0 Group.qml KeyValue 1.0 KeyValue.qml MessageDialog 1.0 MessageDialog.qml Panel 1.0 Panel.qml SearchBar 1.0 SearchBar.qml TabPanel 1.0 TabPanel.qml TextFileViewer 1.0 TextFileViewer.qml ExifOrientedViewer 1.0 ExifOrientedViewer.qml FilterComboBox 1.0 FilterComboBox.qml IntSelector 1.0 IntSelector.qml MScrollBar 1.0 MScrollBar.qml MSplitView 1.0 MSplitView.qml DirectionalLightPane 1.0 DirectionalLightPane.qml SelectionBox 1.0 SelectionBox.qml SelectionLine 1.0 SelectionLine.qml DelegateSelectionBox 1.0 DelegateSelectionBox.qml DelegateSelectionLine 1.0 DelegateSelectionLine.qml StatusBar 1.0 StatusBar.qml NodeActions 1.0 NodeActions.qml ================================================ FILE: meshroom/ui/qml/DialogsFactory.qml ================================================ import QtQuick import Controls 1.0 /** * DialogsFactory is utility object to instantiate generic purpose Dialogs. */ QtObject { readonly property string defaultErrorText: "An unexpected error has occurred" property Component infoDialog: Component { MessageDialog { title: "Info" preset: "Info" visible: true } } property Component warningDialog: Component { MessageDialog { title: "Warning" preset: "Warning" visible: true } } property Component errorDialog: Component { id: errorDialog MessageDialog { title: "Error" preset: "Error" text: defaultErrorText visible: true } } function info(window) { return infoDialog.createObject(window) } function warning(window) { return warningDialog.createObject(window) } function error(window) { return errorDialog.createObject(window) } } ================================================ FILE: meshroom/ui/qml/GraphEditor/AttributeControls/Choice.qml ================================================ import QtQuick import QtQuick.Controls import QtQuick.Layouts import MaterialIcons import Controls /** * A combobox-type control with a single current `value` and a list of possible `values`. * Provides filtering capabilities and support for custom values (i.e: `value` not in `values`). */ RowLayout { id: root required property var value required property var values signal editingFinished(var value) FilterComboBox { id: comboBox Layout.fillWidth: true sourceModel: root.values inputValue: root.value onEditingFinished: value => root.editingFinished(value) } MaterialLabel { visible: !comboBox.validValue text: MaterialIcons.warning ToolTip.text: "Custom value detected" } } ================================================ FILE: meshroom/ui/qml/GraphEditor/AttributeControls/ChoiceMulti.qml ================================================ import QtQuick import QtQuick.Controls import Controls /** * A multi-checkboxes control with a current `value` (list of 0-N elements) and a list of possible `values`. * Provides support for custom values (`value` elements not in `values`). */ Flow { id: root required property var value required property var values property color customValueColor: "orange" signal toggled(var value, var checked) // Predefined possible values. Repeater { model: root.values delegate: CheckBox { text: modelData checked: root.value.includes(modelData) onToggled: root.toggled(modelData, checked) } } // Custom elements outside the predefined possible values. Repeater { model: root.value.filter(v => !root.values.includes(v)) delegate: CheckBox { text: modelData palette.text: root.customValueColor font.italic: true checked: true ToolTip.text: "Custom value" ToolTip.visible: hovered onToggled: root.toggled(modelData, checked) } } } ================================================ FILE: meshroom/ui/qml/GraphEditor/AttributeEditor.qml ================================================ import QtQuick import QtQuick.Controls import Controls 1.0 /** * A component to display and edit the attributes of a Node. */ ListView { id: root property bool readOnly: false property int labelWidth: 180 property bool objectsHideable: true property string filterText: "" signal upgradeRequest() signal attributeDoubleClicked(var mouse, var attribute) signal inAttributeClicked(var srcItem, var mouse, var inAttributes) signal outAttributeClicked(var srcItem, var mouse, var outAttributes) signal showInViewer(var attribute) implicitHeight: contentHeight spacing: 2 clip: true ScrollBar.vertical: MScrollBar { id: scrollBar } delegate: Loader { active: !object.hasDisplayableShape && (object.enabled || object.hasAnyOutputLinks) && ( !objectsHideable || ((!object.desc.advanced || GraphEditorSettings.showAdvancedAttributes) && (object.isDefault && GraphEditorSettings.showDefaultAttributes || !object.isDefault && GraphEditorSettings.showModifiedAttributes) && (object.isOutput && GraphEditorSettings.showOutputAttributes || !object.isOutput && GraphEditorSettings.showInputAttributes) && (object.hasAnyInputLinks && GraphEditorSettings.showLinkAttributes || !object.isLink && GraphEditorSettings.showNotLinkAttributes)) ) && object.matchText(filterText) visible: active sourceComponent: AttributeItemDelegate { width: root.width - scrollBar.width readOnly: root.readOnly labelWidth: root.labelWidth filterText: root.filterText objectsHideable: root.objectsHideable attribute: object onDoubleClicked: function(mouse, attr) { root.attributeDoubleClicked(mouse, attr) } onInAttributeClicked: function(srcItem, mouse, inAttributes) { root.inAttributeClicked(srcItem, mouse, inAttributes) } onOutAttributeClicked: function(srcItem, mouse, outAttributes) { root.outAttributeClicked(srcItem, mouse, outAttributes) } onShowInViewer: function(attr) { root.showInViewer(attr) } } onActiveChanged: height = active ? item.implicitHeight : -spacing Connections { target: item function onImplicitHeightChanged() { // Handles cases where an attribute is created and its height is then updated as it is filled height = item.implicitHeight } } } // Helper MouseArea to lose edit/activeFocus when clicking on the background MouseArea { anchors.fill: parent onClicked: forceActiveFocus() z: -1 } } ================================================ FILE: meshroom/ui/qml/GraphEditor/AttributeItemDelegate.qml ================================================ import QtQuick import QtQuick.Layouts import QtQuick.Controls import QtQuick.Dialogs import MaterialIcons 2.2 import Utils 1.0 import Controls 1.0 import "AttributeControls" as AttributeControls /** * Instantiate a control to visualize and edit an Attribute based on its type. */ RowLayout { id: root property variant attribute: null property bool readOnly: false // Whether the attribute's value can be modified property bool objectsHideable: true property string filterText: "" property alias label: parameterLabel // Accessor to the internal Label (attribute's name) property int labelWidth // Shortcut to set the fixed size of the Label readonly property bool editable: !attribute.isOutput && !attribute.isLink && !readOnly && !(attribute.keyable && _currentScene.selectedViewId === "-1") signal doubleClicked(var mouse, var attr) signal inAttributeClicked(var srcItem, var mouse, var inAttributes) signal outAttributeClicked(var srcItem, var mouse, var outAttributes) signal showInViewer(var attr) spacing: 2 function updateAttributeLabel() { background.color = attribute.isValid ? Qt.darker(palette.window, 1.1) : Qt.darker(Colors.red, 1.5) if (attribute.desc) { var tooltip = "" if (!attribute.isValid && attribute.desc.errorMessage !== "") tooltip += "Error: " + Format.plainToHtml(attribute.desc.errorMessage) + "

" tooltip += " " + attribute.desc.name + ": " + attribute.type + "
" + Format.plainToHtml(attribute.desc.description) parameterTooltip.text = tooltip } } Pane { background: Rectangle { id: background color: object != undefined && object.isValid ? Qt.darker(parent.palette.window, 1.1) : Qt.darker(Colors.red, 1.5) } padding: 0 Layout.preferredWidth: labelWidth || implicitWidth Layout.fillHeight: true RowLayout { spacing: 0 width: parent.width height: parent.height // In connection MaterialToolButton { id: navButtonIn property bool shouldBeVisible: (object != undefined && object.hasAnyInputLinks) text: MaterialIcons.login enabled: shouldBeVisible font.pointSize: 8 Layout.fillHeight: true visible: shouldBeVisible MouseArea { anchors.fill: parent acceptedButtons: Qt.LeftButton | Qt.MiddleButton | Qt.RightButton onClicked: function(mouse) { root.inAttributeClicked(navButtonIn, mouse, object.allInputLinks) } } } Label { id: parameterLabel Layout.fillHeight: true Layout.fillWidth: true horizontalAlignment: attribute.isOutput ? Qt.AlignRight : Qt.AlignLeft verticalAlignment: Text.AlignVCenter elide: Label.ElideRight padding: 5 wrapMode: Label.WrapAtWordBoundaryOrAnywhere text: object.label color: { if (object != undefined && (object.hasAnyOutputLinks || object.isLink) && !object.enabled) return Colors.lightgrey else return palette.text } // Tooltip hint with attribute's description ToolTip { id: parameterTooltip // Position in y at mouse position y: parameterMA.mouseY + 10 text: { var tooltip = "" if (!object.isValid && object.desc.errorMessage !== "") tooltip += "Error: " + Format.plainToHtml(object.desc.errorMessage) + "

" tooltip += "" + object.desc.name + ": " + attribute.type + "
" + Format.plainToHtml(object.desc.description) return tooltip } visible: parameterMA.containsMouse delay: 800 } // Make label bold if attribute's value is not the default one font.bold: !object.isOutput && !object.isDefault // Make label italic if attribute is a link font.italic: object.isLink MouseArea { id: parameterMA anchors.fill: parent hoverEnabled: true acceptedButtons: Qt.AllButtons onDoubleClicked: function(mouse) { root.doubleClicked(mouse, root.attribute) } property Component menuComp: Menu { id: paramMenu property bool isFileAttribute: attribute.type === "File" property bool isFilepath: isFileAttribute && Filepath.isFile(attribute.evalValue) MenuItem { text: "Reset To Default Value" enabled: root.editable && !attribute.isDefault onTriggered: { _currentScene.resetAttribute(attribute) updateAttributeLabel() } } MenuItem { text: "Copy" enabled: !attribute.keyable && attribute.value != "" onTriggered: { Clipboard.clear() Clipboard.setText(attribute.value) } } MenuItem { text: "Paste" enabled: Clipboard.getText() != "" && !attribute.keyable && root.editable onTriggered: { _currentScene.setAttribute(attribute, Clipboard.getText()) } } MenuSeparator { visible: paramMenu.isFileAttribute height: visible ? implicitHeight : 0 } MenuItem { visible: paramMenu.isFileAttribute height: visible ? implicitHeight : 0 text: paramMenu.isFilepath ? "Open Containing Folder" : "Open Folder" onClicked: paramMenu.isFilepath ? Qt.openUrlExternally(Filepath.dirname(attribute.evalValue)) : Qt.openUrlExternally(Filepath.stringToUrl(attribute.evalValue)) } MenuItem { visible: paramMenu.isFilepath height: visible ? implicitHeight : 0 text: "Open File" onClicked: Qt.openUrlExternally(Filepath.stringToUrl(attribute.evalValue)) } MenuItem { visible: attribute.isOutput && (attribute.is2dDisplayable || attribute.is3dDisplayable || attribute.isTextDisplayable) height: visible ? implicitHeight : 0 text: { if (attribute.is2dDisplayable) return "Show in 2D Viewer" if (attribute.isTextDisplayable) return "Show in Text Viewer" return "Show in 3D Viewer" } onClicked: root.showInViewer(attribute) } } onClicked: function(mouse) { forceActiveFocus() if (mouse.button == Qt.RightButton) { var menu = menuComp.createObject(parameterLabel) menu.parent = parameterLabel menu.popup() } } } } MaterialLabel { property bool isDisplayable: attribute.isOutput && (attribute.is2dDisplayable || attribute.is3dDisplayable || attribute.isTextDisplayable) property bool isDisplayed: attribute === _currentScene.displayedAttr2D || _currentScene.displayedAttrs3D.count && _currentScene.displayedAttrs3D.contains(attribute) text: isDisplayed ? MaterialIcons.visibility : MaterialIcons.visibility_off enabled: isDisplayed visible: isDisplayable ToolTip.text: { if (attribute.is2dDisplayable) return "This attribute is displayable in the 2D viewer." if (attribute.isTextDisplayable) return "This attribute is displayable in the Text viewer." return "This attribute is displayable in the 3D viewer." } padding: 4 font.pointSize: 8 } MaterialToolButton { id: navButtonOut property bool shouldBeVisible: (attribute != undefined && attribute.hasAnyOutputLinks) text: MaterialIcons.logout font.pointSize: 8 enabled: shouldBeVisible Layout.fillHeight: true visible: shouldBeVisible MouseArea { anchors.fill: parent acceptedButtons: Qt.LeftButton | Qt.MiddleButton | Qt.RightButton onClicked: function(mouse) { root.outAttributeClicked(navButtonOut, mouse, attribute.allOutputLinks) } } } MaterialLabel { visible: attribute.desc.advanced text: MaterialIcons.build color: palette.mid font.pointSize: 8 padding: 4 } } } function setTextFieldAttribute(value) { // editingFinished called even when TextField is readonly if (!editable) return switch (attribute.type) { case "IntParam": case "FloatParam": // We do not set a number because we want to keep the invalid expression if(attribute.keyable) _currentScene.addAttributeKeyValue(root.attribute, _currentScene.selectedViewId, Number(value)) else _currentScene.setAttribute(root.attribute, Number(value)) updateAttributeLabel() break case "File": _currentScene.setAttribute(root.attribute, value) break default: _currentScene.setAttribute(root.attribute, value.trim()) updateAttributeLabel() break } } Loader { id: attributeLoader Layout.fillWidth: true sourceComponent: { // PushButtonParam always has value == undefined, so it needs to be excluded from this check if (attribute.type != "PushButtonParam" && !attribute.keyable && attribute.value === undefined) { return notComputedComponent } switch (attribute.type) { case "PushButtonParam": return pushButtonComponent case "ChoiceParam": return attribute.desc.exclusive ? choiceComponent : choiceMultiComponent case "IntParam": return sliderComponent case "FloatParam": if (attribute.desc.semantic === 'color/hue') return colorHueComponent return sliderComponent case "BoolParam": return checkboxComponent case "ListAttribute": return listAttributeComponent case "GroupAttribute": return groupAttributeComponent case "StringParam": if (attribute.desc.semantic.includes('multiline')) return textAreaComponent return textFieldComponent case "ColorParam": return colorComponent default: return textFieldComponent } } Component { id: notComputedComponent MaterialLabel { anchors.fill: parent text: MaterialIcons.do_not_disturb_alt horizontalAlignment: Text.AlignHCenter verticalAlignment: Text.AlignVCenter padding: 4 background: Rectangle { anchors.fill: parent border.width: 0 radius: 20 color: Qt.darker(palette.window, 1.1) } } } Component { id: pushButtonComponent Button { text: attribute.label enabled: root.editable onClicked: { attribute.clicked() } } } Component { id: textFieldComponent TextField { id: textField readOnly: !root.editable text: attribute.value // Don't disable the component to keep interactive features (text selection, context menu...). // Only override the look by using the Disabled palette. SystemPalette { id: disabledPalette colorGroup: SystemPalette.Disabled } states: [ State { when: readOnly PropertyChanges { target: textField color: disabledPalette.text } } ] selectByMouse: true onEditingFinished: setTextFieldAttribute(text) persistentSelection: false onAccepted: { setTextFieldAttribute(text) parameterLabel.forceActiveFocus() } Keys.onPressed: function(event) { if ((event.key == Qt.Key_Escape)) { event.accepted = true parameterLabel.forceActiveFocus() } } Component.onDestruction: { if (activeFocus) setTextFieldAttribute(text) } DropArea { enabled: root.editable anchors.fill: parent onDropped: function(drop) { if (drop.hasUrls) setTextFieldAttribute(Filepath.urlToString(drop.urls[0])) else if (drop.hasText && drop.text != '') setTextFieldAttribute(drop.text) } } onPressed: (event) => { if(event.button == Qt.RightButton) { // Keep selection persistent while context menu is open to // visualize what is being copied or what will be replaced on paste. persistentSelection = true; const menu = textFieldMenuComponent.createObject(textField); menu.popup(); if(selectedText === "") { cursorPosition = positionAt(event.x, event.y); } } } Component { id: textFieldMenuComponent Menu { onOpened: { // Keep cursor visible to see where pasting would happen. textField.cursorVisible = true; } onClosed: { // Disable selection persistency behavior once menu is closed and // give focus back to the parent TextField. textField.persistentSelection = false; textField.forceActiveFocus(); destroy(); } MenuItem { text: "Copy" enabled: attribute.value != "" onTriggered: { const hasSelection = textField.selectionStart !== textField.selectionEnd; if(hasSelection) { // Use `TextField.copy` to copy only the current selection. textField.copy(); } else { Clipboard.setText(attribute.value); } } } MenuItem { text: "Paste" enabled: !readOnly onTriggered: { const clipboardText = Clipboard.getText(); if (clipboardText.length === 0) { return; } const before = textField.text.substr(0, textField.selectionStart); const after = textField.text.substr(textField.selectionEnd, textField.text.length); const updatedValue = before + clipboardText + after; setTextFieldAttribute(updatedValue); // Set the cursor at the end of the added text textField.cursorPosition = before.length + clipboardText.length; } } } } } } Component { id: textAreaComponent Rectangle { // Fixed background for the flickable object color: palette.base width: parent.width height: attribute.desc.semantic.includes("large") ? 400 : 70 Flickable { width: parent.width height: parent.height contentWidth: width contentHeight: height ScrollBar.vertical: MScrollBar {} TextArea.flickable: TextArea { wrapMode: Text.WordWrap padding: 0 rightPadding: 5 bottomPadding: 2 topPadding: 2 readOnly: !root.editable onEditingFinished: setTextFieldAttribute(text) text: attribute.value selectByMouse: true onPressed: { root.forceActiveFocus() } Component.onDestruction: { if (activeFocus) setTextFieldAttribute(text) } DropArea { enabled: root.editable anchors.fill: parent onDropped: { if (drop.hasUrls) setTextFieldAttribute(Filepath.urlToString(drop.urls[0])) else if (drop.hasText && drop.text != '') setTextFieldAttribute(drop.text) } } } } } } Component { id: colorComponent RowLayout { CheckBox { id: colorCheckbox Layout.alignment: Qt.AlignLeft checked: attribute.value === "" ? false : true checkable: root.editable text: "Custom Color" property string previousColor: "" onClicked: { if (checked) { if (colorText.text == "") { if (previousColor != "") _currentScene.setAttribute(attribute, previousColor) else _currentScene.setAttribute(attribute, "#0000FF") } else _currentScene.setAttribute(attribute, colorText.text) } else { previousColor = attribute.value _currentScene.setAttribute(attribute, "") } } } TextField { id: colorText Layout.alignment: Qt.AlignLeft implicitWidth: 100 enabled: colorCheckbox.checked && root.editable visible: colorCheckbox.checked text: colorCheckbox.checked ? attribute.value : "" selectByMouse: true onEditingFinished: setTextFieldAttribute(text) onAccepted: setTextFieldAttribute(text) Component.onDestruction: { if (activeFocus) setTextFieldAttribute(text) } } Rectangle { height: colorText.height width: colorText.width / 2 Layout.alignment: Qt.AlignLeft visible: colorCheckbox.checked color: colorCheckbox.checked ? colorDialog.selectedColor : "" MouseArea { enabled: root.editable anchors.fill: parent onClicked: colorDialog.open() } } ColorDialog { id: colorDialog title: "Please choose a color" selectedColor: colorText.text onAccepted: { colorText.text = colorDialog.selectedColor // Artificially trigger change of attribute value colorText.editingFinished() close() } onRejected: close() } Item { // Dummy item to fill out the space if needed Layout.fillWidth: true } } } Component { id: choiceComponent AttributeControls.Choice { value: root.attribute.value values: root.attribute.values enabled: root.editable onEditingFinished: (value) => { _currentScene.setAttribute(root.attribute, value) } } } Component { id: choiceMultiComponent AttributeControls.ChoiceMulti { value: root.attribute.value values: root.attribute.values enabled: root.editable customValueColor: Colors.orange onToggled: (value, checked) => { var currentValue = root.attribute.value; if (!checked) { currentValue.splice(currentValue.indexOf(value), 1); } else { currentValue.push(value); } _currentScene.setAttribute(attribute, currentValue); } } } Component { id: sliderComponent RowLayout { ExpressionTextField { id: expressionTextField implicitWidth: 100 Layout.fillWidth: !slider.active enabled: root.editable // Cast value to string to avoid intrusive scientific notations on numbers property string displayValue: String(slider.active && slider.item.pressed ? slider.item.formattedValue : attribute.keyable ? attribute.keyValues.getValueAtKeyOrDefault(_currentScene.selectedViewId) : attribute.value) text: displayValue selectByMouse: true // Note: Use autoScroll as a workaround for alignment // When the value change keep the text align to the left to be able to read the most important part // of the number. When we are editing (item is in focus), the content should follow the editing. autoScroll: activeFocus isInt: attribute.type === "FloatParam" ? false : true onEditingFinished: { if (!hasExprError) { setTextFieldAttribute(expressionTextField.evaluatedValue) // Restore binding expressionTextField.text = Qt.binding(function() { return String(expressionTextField.displayValue); }) } } onAccepted: { if (!hasExprError) { setTextFieldAttribute(expressionTextField.evaluatedValue) // Restore binding expressionTextField.text = Qt.binding(function() { return String(expressionTextField.displayValue); }) } // When the text is too long, display the left part // (with the most important values and cut the floating point details) ensureVisible(0) } Component.onDestruction: { if (activeFocus) { if (!hasExprError) setTextFieldAttribute(expressionTextField.evaluatedValue) } } Component.onCompleted: { // When the text is too long, display the left part // (with the most important values and cut the floating point details) ensureVisible(0) } } Loader { id: slider Layout.fillWidth: true active: attribute.desc.range.length === 3 sourceComponent: Slider { readonly property int stepDecimalCount: stepSize < 1 ? String(stepSize).split(".").pop().length : 0 readonly property real formattedValue: value.toFixed(stepDecimalCount) enabled: root.editable value: attribute.keyable ? attribute.keyValues.getValueAtKeyOrDefault(_currentScene.selectedViewId) : attribute.value from: attribute.desc.range[0] to: attribute.desc.range[1] stepSize: attribute.desc.range[2] snapMode: Slider.SnapAlways onPressedChanged: { if (!pressed) { if(attribute.keyable) _currentScene.addAttributeKeyValue(attribute, _currentScene.selectedViewId, formattedValue) else _currentScene.setAttribute(attribute, formattedValue) updateAttributeLabel() } } } } } } Component { id: checkboxComponent Row { CheckBox { enabled: root.editable checked: attribute.keyable ? attribute.keyValues.getValueAtKeyOrDefault(_currentScene.selectedViewId) : attribute.value onToggled: { if(attribute.keyable) { const value = attribute.keyValues.getValueAtKeyOrDefault(_currentScene.selectedViewId) _currentScene.addAttributeKeyValue(attribute, _currentScene.selectedViewId, !value) } else { _currentScene.setAttribute(attribute, !attribute.value) } } } } } Component { id: listAttributeComponent ColumnLayout { id: listAttributeLayout width: parent.width property bool expanded: false RowLayout { spacing: 4 ToolButton { text: listAttributeLayout.expanded ? MaterialIcons.keyboard_arrow_down : MaterialIcons.keyboard_arrow_right font.family: MaterialIcons.fontFamily onClicked: listAttributeLayout.expanded = !listAttributeLayout.expanded } Label { Layout.alignment: Qt.AlignVCenter text: attribute.value.count + " elements" } ToolButton { text: MaterialIcons.add_circle_outline font.family: MaterialIcons.fontFamily font.pointSize: 11 padding: 2 enabled: root.editable onClicked: _currentScene.appendAttribute(attribute, undefined) } } ListView { id: lv model: listAttributeLayout.expanded ? attribute.value : undefined visible: model !== undefined && count > 0 implicitHeight: Math.min(contentHeight, 300) Layout.fillWidth: true Layout.margins: 4 clip: true spacing: 4 ScrollBar.vertical: MScrollBar { id: sb } delegate: Loader { active: !objectsHideable || ((object.isDefault && GraphEditorSettings.showDefaultAttributes || !object.isDefault && GraphEditorSettings.showModifiedAttributes) && (object.hasAnyInputLinks && GraphEditorSettings.showLinkAttributes || !object.hasAnyInputLinks && GraphEditorSettings.showNotLinkAttributes)) visible: active sourceComponent: RowLayout { id: item property var childAttrib: object layoutDirection: Qt.RightToLeft width: lv.width - sb.width Component.onCompleted: { var cpt = Qt.createComponent("AttributeItemDelegate.qml") var obj = cpt.createObject(item, { 'attribute': Qt.binding(function() { return item.childAttrib }), 'readOnly': Qt.binding(function() { return !root.editable }) }) obj.Layout.fillWidth = true obj.label.text = index obj.label.horizontalAlignment = Text.AlignHCenter obj.label.verticalAlignment = Text.AlignVCenter obj.doubleClicked.connect(function(attr) { root.doubleClicked(attr) }) obj.inAttributeClicked.connect(function(srcItem, mouse, inAttributes) { root.inAttributeClicked(srcItem, mouse, inAttributes) }) obj.outAttributeClicked.connect(function(srcItem, mouse, outAttributes) { root.outAttributeClicked(srcItem, mouse, outAttributes) }) } ToolButton { enabled: root.editable text: MaterialIcons.remove_circle_outline font.family: MaterialIcons.fontFamily font.pointSize: 11 padding: 2 ToolTip.text: "Remove Element" ToolTip.visible: hovered onClicked: _currentScene.removeAttribute(item.childAttrib) } } } } } } Component { id: groupAttributeComponent ColumnLayout { id: groupItem Component.onCompleted: { var cpt = Qt.createComponent("AttributeEditor.qml"); var obj = cpt.createObject(groupItem, { 'model': Qt.binding(function() { return attribute.value }), 'readOnly': Qt.binding(function() { return root.readOnly }), 'labelWidth': 100, // Reduce label width for children (space gain) 'objectsHideable': Qt.binding(function() { return root.objectsHideable }), 'filterText': Qt.binding(function() { return root.filterText }), }) obj.Layout.fillWidth = true; obj.attributeDoubleClicked.connect( function(attr) { root.doubleClicked(attr) } ) obj.inAttributeClicked.connect( function(srcItem, mouse, inAttributes) { root.inAttributeClicked(srcItem, mouse, inAttributes) } ) obj.outAttributeClicked.connect( function(srcItem, mouse, outAttributes) { root.outAttributeClicked(srcItem, mouse, outAttributes) } ) } } } Component { id: colorHueComponent RowLayout { TextField { implicitWidth: 100 enabled: root.editable // Cast value to string to avoid intrusive scientific notations on numbers property string displayValue: String(slider.pressed ? slider.formattedValue : attribute.value) text: displayValue selectByMouse: true validator: DoubleValidator { locale: 'C' // Use '.' decimal separator disregarding the system locale } onEditingFinished: setTextFieldAttribute(text) onAccepted: setTextFieldAttribute(text) Component.onDestruction: { if (activeFocus) setTextFieldAttribute(text) } } Rectangle { height: slider.height width: height color: Qt.hsla(slider.pressed ? slider.formattedValue : attribute.value, 1, 0.5, 1) } Slider { id: slider Layout.fillWidth: true readonly property int stepDecimalCount: 2 readonly property real formattedValue: value.toFixed(stepDecimalCount) enabled: root.editable value: attribute.value from: 0 to: 1 stepSize: 0.01 snapMode: Slider.SnapAlways onPressedChanged: { if (!pressed) _currentScene.setAttribute(attribute, formattedValue) } background: ShaderEffect { width: slider.availableWidth height: slider.availableHeight blending: false fragmentShader: "qrc:/shaders/AttributeItemDelegate.frag.qsb" } } } } } // Add or remove key button for keyable attribute Loader { active: attribute.keyable sourceComponent: MaterialToolButton { font.pointSize: 5 padding: 6 text: MaterialIcons.circle checkable: true checked: attribute.keyable && attribute.keyValues.hasKey(_currentScene.selectedViewId) enabled: root.editable onClicked: { if (attribute.keyValues.hasKey(_currentScene.selectedViewId)) _currentScene.removeAttributeKey(attribute, _currentScene.selectedViewId) else _currentScene.addAttributeKeyDefaultValue(attribute, _currentScene.selectedViewId) } } } } ================================================ FILE: meshroom/ui/qml/GraphEditor/AttributePin.qml ================================================ import QtQuick import QtQuick.Controls import QtQuick.Layouts import Utils 1.0 import MaterialIcons 2.2 /** * The representation of an Attribute on a Node. */ RowLayout { id: root property var nodeItem property var attribute property bool expanded: false property bool readOnly: false /// Whether to display an output pin for input attribute property bool displayOutputPinForInput: true // position of the anchor for attaching and edge to this attribute pin readonly property point inputAnchorPos: Qt.point(inputAnchor.x + inputAnchor.width / 2, inputAnchor.y + inputAnchor.height / 2) readonly property point outputAnchorPos: Qt.point(outputAnchor.x + outputAnchor.width / 2, outputAnchor.y + outputAnchor.height / 2) readonly property bool isList: attribute && attribute.type === "ListAttribute" readonly property bool isGroup: attribute && attribute.type === "GroupAttribute" readonly property bool isConnected: attribute.hasAnyInputLinks || attribute.hasAnyOutputLinks signal childPinCreated(var childAttribute, var pin) signal childPinDeleted(var childAttribute, var pin) signal pressed(var mouse) signal edgeAboutToBeRemoved(var input) signal clicked() objectName: attribute ? attribute.name + "." : "" layoutDirection: Qt.LeftToRight spacing: 3 ToolTip { text: attribute.fullName + ": " + attribute.type visible: nameLabel.hovered delay: 500 x: nameLabel.x y: nameLabel.y + nameLabel.height } // Instantiate empty Items for each child attribute Repeater { id: childrenRepeater model: root.isList && !root.attribute.isLink ? root.attribute.value : 0 onItemAdded: function(index, item) { childPinCreated(item.childAttribute, root) } onItemRemoved: function(index, item) { childPinDeleted(item.childAttribute, root) } delegate: Item { property var childAttribute: object visible: false } } Item { width: childrenRect.width Layout.alignment: Qt.AlignVCenter Layout.fillWidth: true Layout.fillHeight: true Rectangle { id: inputAnchor visible: !root.attribute.isOutput width: 8 height: width radius: root.isList ? 0 : width / 2 Layout.alignment: Qt.AlignVCenter border.color: { if (innerInputAnchor.hasConnectedChildren) return Colors.sysPalette.text return Colors.sysPalette.mid } color: Colors.sysPalette.base Rectangle { id: innerInputAnchor property bool linkEnabled: true property bool hasConnectedChildren: { if (!root.isGroup || root.isConnected || !attribute) return false for (var i = 0; i < attribute.flatStaticChildren.length; ++i) { if (attribute.flatStaticChildren[i].hasAnyInputLinks) { return true } } return false } visible: inputConnectMA.containsMouse || childrenRepeater.count > 0 || hasConnectedChildren || (root.attribute && root.attribute.isLink && linkEnabled) || inputConnectMA.drag.active || inputDropArea.containsDrag radius: root.isList ? 0 : 2 anchors.fill: parent anchors.margins: 2 color: { if (inputConnectMA.containsMouse || inputConnectMA.drag.active || (inputDropArea.containsDrag && inputDropArea.acceptableDrop)) return Colors.sysPalette.highlight if (hasConnectedChildren) return Colors.sysPalette.mid return Colors.sysPalette.text } } DropArea { id: inputDropArea property bool acceptableDrop: false // Add negative margins for DropArea to make the connection zone easier to reach anchors.fill: parent anchors.margins: -2 // Add horizontal negative margins according to the current layout anchors.rightMargin: -root.width * 0.3 keys: [inputDragTarget.objectName] onEntered: function(drag) { var validIncomingConnection = drag.source.attribute.validateIncomingConnection(inputDragTarget.attribute) // Check if attributes are compatible to create a valid connection if (root.readOnly // Cannot connect on a read-only attribute || drag.source.objectName != inputDragTarget.objectName // Not an edge connector || !validIncomingConnection // Connection is not allowed || drag.source.nodeItem === inputDragTarget.nodeItem // Connection between attributes of the same node || drag.source.isList && childrenRepeater.count // Source/target are lists but target already has children || drag.source.connectorType === "input" // Refuse to connect an "input pin" on another one (input attr can be connected to input attr, but not the graphical pin) ) { // Refuse attributes connection drag.accepted = false } else if (inputDragTarget.attribute.isLink) { // Already connected attribute root.edgeAboutToBeRemoved(inputDragTarget.attribute) } inputDropArea.acceptableDrop = drag.accepted } onExited: { if (inputDragTarget.attribute.isLink) { // Already connected attribute root.edgeAboutToBeRemoved(undefined) } acceptableDrop = false drag.source.dropAccepted = false } onDropped: function(drop) { root.edgeAboutToBeRemoved(undefined) _currentScene.addEdge(drag.source.attribute, inputDragTarget.attribute) } } Item { id: inputDragTarget objectName: "edgeConnector" readonly property string connectorType: "input" readonly property alias attribute: root.attribute readonly property alias nodeItem: root.nodeItem readonly property bool isOutput: Boolean(attribute.isOutput) readonly property alias isList: root.isList readonly property alias isGroup: root.isGroup property bool dragAccepted: false anchors.verticalCenter: parent.verticalCenter anchors.horizontalCenter: parent.horizontalCenter width: parent.width height: parent.height Drag.keys: [inputDragTarget.objectName] Drag.active: inputConnectMA.drag.active Drag.hotSpot.x: width * 0.5 Drag.hotSpot.y: height * 0.5 } MouseArea { id: inputConnectMA drag.target: root.attribute.isReadOnly ? undefined : inputDragTarget drag.threshold: 0 // Move the edge's tip straight to the current mouse position instead of waiting after the drag operation has started drag.smoothed: false enabled: !root.readOnly anchors.fill: parent hoverEnabled: root.visible // Use the same negative margins as DropArea to ease pin selection anchors.margins: inputDropArea.anchors.margins anchors.leftMargin: inputDropArea.anchors.leftMargin anchors.rightMargin: inputDropArea.anchors.rightMargin property bool dragTriggered: false // An edge is being dragged from the input connector property bool isPressed: false // The mouse has been pressed but not released yet property double initialX: 0.0 property double initialY: 0.0 onPressed: function(mouse) { root.pressed(mouse) isPressed = true initialX = mouse.x initialY = mouse.y } onReleased: { inputDragTarget.Drag.drop() isPressed = false dragTriggered = false } onClicked: function() { root.clicked() } onPositionChanged: function(mouse) { // If there has been a significant move (5px along the -X or -Y axis) while the // mouse is being pressed, then we can consider being in the dragging state if (isPressed && (Math.abs(mouse.x - initialX) >= 5.0 || Math.abs(mouse.y - initialY) >= 5.0)) { dragTriggered = true } } } Edge { id: inputConnectEdge visible: false point1x: inputDragTarget.x + inputDragTarget.width / 2 point1y: inputDragTarget.y + inputDragTarget.height / 2 point2x: parent.width / 2 point2y: parent.width / 2 color: palette.highlight thickness: outputDragTarget.dropAccepted ? 2 : 1 } } } // Attribute name Item { id: nameContainer implicitHeight: childrenRect.height implicitWidth: childrenRect.width Layout.fillWidth: true Layout.fillHeight: true Layout.alignment: Qt.AlignVCenter MaterialToolLabel { id: nameLabel anchors.fill: parent Layout.fillWidth: true Layout.fillHeight: true anchors.verticalCenter: parent.verticalCenter anchors.margins: 0 labelIconRow.layoutDirection: root.attribute.isOutput ? Qt.RightToLeft : Qt.LeftToRight labelIconRow.spacing: 0 enabled: !root.readOnly visible: true // Allow to trigger a change of state once the parent is ready, ensuring the correct width of the // elements upon their first display without waiting for a mouse interaction property bool parentNotReady: nameContainer.width == 0 property bool hovered: parentNotReady || (inputConnectMA.containsMouse || inputConnectMA.drag.active || inputDropArea.containsDrag || outputConnectMA.containsMouse || outputConnectMA.drag.active || outputDropArea.containsDrag) labelIconColor: { if ((root.attribute.hasAnyOutputLinks || root.attribute.isLink) && !root.attribute.enabled) { return Colors.lightgrey } else if (hovered) { return palette.highlight } return palette.text } labelIconMouseArea.enabled: false // Prevent mixing mouse interactions between the label and the pin context // Text label.text: root.attribute.label label.font.pointSize: 7 label.elide: hovered ? Text.ElideNone : Text.ElideMiddle label.horizontalAlignment: root.attribute && root.attribute.isOutput ? Text.AlignRight : Text.AlignLeft label.verticalAlignment: Text.AlignVCenter label.visible: true // Icon iconText: { if (root.isGroup) { return root.expanded ? MaterialIcons.expand_more : MaterialIcons.chevron_right } return "" } iconSize: 7 icon.horizontalAlignment: root.attribute && root.attribute.isOutput ? Text.AlignRight : Text.AlignLeft icon.verticalAlignment: Text.AlignVCenter // Handle tree view for nested attributes property int groupPaddingWidth: root.attribute.depth * 10 icon.leftPadding: root.attribute.isOutput ? 0 : groupPaddingWidth icon.rightPadding: root.attribute.isOutput ? groupPaddingWidth : 0 } } Rectangle { id: outputAnchor visible: root.displayOutputPinForInput || root.attribute.isOutput width: 8 height: width radius: root.isList ? 0 : width / 2 Layout.alignment: Qt.AlignVCenter border.color: { if (innerOutputAnchor.hasConnectedChildren) return Colors.sysPalette.text return Colors.sysPalette.mid } color: Colors.sysPalette.base Rectangle { id: innerOutputAnchor property bool linkEnabled: true property bool hasConnectedChildren: { if (!root.isGroup || root.isConnected) return false for (var i = 0; i < attribute.flatStaticChildren.length; ++i) { if (attribute.flatStaticChildren[i].hasAnyOutputLinks) { return true } } return false } visible: (root.attribute.hasAnyOutputLinks && linkEnabled) || outputConnectMA.containsMouse || outputConnectMA.drag.active || outputDropArea.containsDrag || hasConnectedChildren radius: root.isList ? 0 : 2 anchors.fill: parent anchors.margins: 2 color: { if (root.attribute.enabled && (outputConnectMA.containsMouse || outputConnectMA.drag.active || (outputDropArea.containsDrag && outputDropArea.acceptableDrop))) return Colors.sysPalette.highlight if (hasConnectedChildren) return Colors.sysPalette.mid return Colors.sysPalette.text } } DropArea { id: outputDropArea property bool acceptableDrop: false // Add negative margins for DropArea to make the connection zone easier to reach anchors.fill: parent anchors.margins: -2 // Add horizontal negative margins according to the current layout anchors.leftMargin: -root.width * 0.2 keys: [outputDragTarget.objectName] onEntered: function(drag) { var validIncomingConnection = outputDragTarget.attribute.validateIncomingConnection(drag.source.attribute) // Check if attributes are compatible to create a valid connection if (drag.source.objectName != outputDragTarget.objectName // Not an edge connector || !validIncomingConnection // Connection is not allowed || drag.source.nodeItem === outputDragTarget.nodeItem // Connection between attributes of the same node || (!drag.source.isList && outputDragTarget.isList) // Connection between a list and a simple attribute || (drag.source.isList && childrenRepeater.count) // Source/target are lists but target already has children || drag.source.connectorType === "output" // Refuse to connect an output pin on another one ) { // Refuse attributes connection drag.accepted = false } else if (drag.source.attribute.isLink) { // Already connected attribute root.edgeAboutToBeRemoved(drag.source.attribute) } outputDropArea.acceptableDrop = drag.accepted } onExited: { root.edgeAboutToBeRemoved(undefined) acceptableDrop = false } onDropped: function(drop) { root.edgeAboutToBeRemoved(undefined) _currentScene.addEdge(outputDragTarget.attribute, drag.source.attribute) } } Item { id: outputDragTarget objectName: "edgeConnector" readonly property string connectorType: "output" readonly property alias attribute: root.attribute readonly property alias nodeItem: root.nodeItem readonly property bool isOutput: Boolean(attribute.isOutput) readonly property alias isList: root.isList readonly property alias isGroup: root.isGroup property bool dropAccepted: false anchors.horizontalCenter: parent.horizontalCenter anchors.verticalCenter: parent.verticalCenter width: parent.width height: parent.height Drag.keys: [outputDragTarget.objectName] Drag.active: outputConnectMA.drag.active Drag.hotSpot.x: width * 0.5 Drag.hotSpot.y: height * 0.5 } MouseArea { id: outputConnectMA drag.target: outputDragTarget drag.threshold: 0 // Move the edge's tip straight to the current mouse position instead of waiting after the drag operation has started drag.smoothed: false anchors.fill: parent // Use the same negative margins as DropArea to ease pin selection anchors.margins: outputDropArea.anchors.margins anchors.leftMargin: outputDropArea.anchors.leftMargin anchors.rightMargin: outputDropArea.anchors.rightMargin hoverEnabled: root.visible property bool dragTriggered: false // An edge is being dragged from the output connector property bool isPressed: false // The mouse has been pressed but not released yet property double initialX: 0.0 property double initialY: 0.0 onPressed: function(mouse) { root.pressed(mouse) isPressed = true initialX = mouse.x initialY = mouse.y } onReleased: function(mouse) { outputDragTarget.Drag.drop() isPressed = false dragTriggered = false } onClicked: function() { root.clicked() } onPositionChanged: function(mouse) { // If there has been a significant move (5px along the -X or -Y axis) while the mouse is being // pressed, then we can consider being in the dragging state. if (isPressed && (Math.abs(mouse.x - initialX) >= 5.0 || Math.abs(mouse.y - initialY) >= 5.0)) { dragTriggered = true } } } Edge { id: outputConnectEdge visible: false point1x: parent.width / 2 point1y: parent.width / 2 point2x: outputDragTarget.x + outputDragTarget.width / 2 point2y: outputDragTarget.y + outputDragTarget.height / 2 color: palette.highlight thickness: outputDragTarget.dropAccepted ? 2 : 1 } } state: inputConnectMA.dragTriggered ? "DraggingInput" : outputConnectMA.dragTriggered ? "DraggingOutput" : "" states: [ State { name: "" AnchorChanges { target: outputDragTarget anchors.horizontalCenter: outputAnchor.horizontalCenter anchors.verticalCenter: outputAnchor.verticalCenter } AnchorChanges { target: inputDragTarget anchors.horizontalCenter: inputAnchor.horizontalCenter anchors.verticalCenter: inputAnchor.verticalCenter } PropertyChanges { target: inputDragTarget x: 0 y: 0 } PropertyChanges { target: outputDragTarget x: 0 y: 0 } }, State { name: "DraggingInput" AnchorChanges { target: inputDragTarget anchors.horizontalCenter: undefined anchors.verticalCenter: undefined } PropertyChanges { target: inputConnectEdge z: 100 visible: true } StateChangeScript { script: { // Add the right offset if the initial click is not exactly at the center of the connection circle. var pos = inputDragTarget.mapFromItem(inputConnectMA, inputConnectMA.mouseX, inputConnectMA.mouseY); inputDragTarget.x = pos.x - inputDragTarget.width / 2; inputDragTarget.y = pos.y - inputDragTarget.height / 2; } } }, State { name: "DraggingOutput" AnchorChanges { target: outputDragTarget anchors.horizontalCenter: undefined anchors.verticalCenter: undefined } PropertyChanges { target: outputConnectEdge z: 100 visible: true } StateChangeScript { script: { var pos = outputDragTarget.mapFromItem(outputConnectMA, outputConnectMA.mouseX, outputConnectMA.mouseY); outputDragTarget.x = pos.x - outputDragTarget.width / 2; outputDragTarget.y = pos.y - outputDragTarget.height / 2; } } } ] } ================================================ FILE: meshroom/ui/qml/GraphEditor/Backdrop.qml ================================================ import QtQuick import QtQuick.Controls import QtQuick.Layouts import Qt5Compat.GraphicalEffects import Utils 1.0 import Meshroom.Helpers /** * Visual representation of a Graph Backdrop Node. */ Item { id: root // The underlying Node object property variant node // Mouse related states property bool mainSelected: false property bool selected: false property bool hovered: false // The item instantiating the delegates property Item modelInstantiator: undefined // Node children for the Backdrop property var children: [] property var childrenIndices: [] property bool ctrlHeld: false property bool dragging: headerMouseArea.drag.active property bool resizing: leftDragger.drag.active || topDragger.drag.active // Combined x and y property point position: Qt.point(x, y) // Styling property color shadowColor: "#000000" readonly property color defaultColor: node.color === "" ? "#fffb85" : node.color property color baseColor: defaultColor readonly property int minimumWidth: 200 readonly property int minimumHeight: 200 // Identifies this delegate as a backdrop node (used e.g. for selection rect intersection tests) readonly property bool isBackdropNode: true // Height of the titlebar, used for selection rect computation readonly property real headerHeight: header.height property point mousePosition: Qt.point(mouseArea.mouseX, mouseArea.mouseY) // Mouse interaction related signals signal pressed(var mouse) signal released(var mouse) signal clicked(var mouse) signal doubleClicked(var mouse) signal moved(var position) signal entered() signal exited() // Size signal signal resized(var width, var height) signal resizedAndMoved(var width, var height, var position) // Already connected attribute with another edge in DropArea signal edgeAboutToBeRemoved(var input) // Emitted when child attribute pins are created signal attributePinCreated(var attribute, var pin) // Emitted when child attribute pins are deleted signal attributePinDeleted(var attribute, var pin) // Use node name as object name to simplify debugging objectName: node ? node.name : "" // initialize position with node coordinates x: root.node ? root.node.x : undefined y: root.node ? root.node.y : undefined // The backdrop node always needs to be at the back z: -1 width: root.node ? root.node.nodeWidth : 300 height: root.node ? root.node.nodeHeight : 200 implicitHeight: childrenRect.height SystemPalette { id: activePalette } Connections { target: root.node function onPositionChanged() { root.x = root.node.x root.y = root.node.y } function onInternalAttributesChanged() { root.width = root.node.nodeWidth root.height = root.node.nodeHeight } } // When the node is selected, update the children for it // For node to consider another node, it needs to be fully inside the backdrop area onSelectedChanged: { if (selected) { updateChildren() } } onPressed: { updateChildren() } function updateChildren() { let indices = [] let nodes = [] const backdropRect = Qt.rect(root.node.x, root.node.y, root.node.nodeWidth, root.node.nodeHeight) for (var i = 0; i < modelInstantiator.count; ++i) { const delegate = modelInstantiator.getItemAt(i) if (!delegate || delegate === this) continue const delegateRect = Qt.rect(delegate.x, delegate.y, delegate.width, delegate.height) if (Geom2D.rectRectFullIntersect(backdropRect, delegateRect)) { indices.push(i) nodes.push(delegate) } } childrenIndices = indices children = nodes } function getChildrenNodes(refresh = false) { // Returns the current nodes which are a part of the Backdrop if (refresh) { updateChildren() } return children } function getChildrenIndices(refresh = false) { // Returns the current nodes' indices which are a part of the Backdrop if (refresh) { updateChildren() } return childrenIndices } // Main Layout MouseArea { id: mouseArea width: root.width height: root.height acceptedButtons: Qt.NoButton hoverEnabled: true onEntered: root.entered() onExited: root.exited() cursorShape: Qt.ArrowCursor // --- Backdrop Resize Controls // Resize: diagonal bottom-right Rectangle { width: 8 height: 8 color: baseColor opacity: 0 anchors.horizontalCenter: parent.right anchors.verticalCenter: parent.bottom MouseArea { id: diagonalDragger cursorShape: Qt.SizeFDiagCursor anchors.fill: parent drag { target: parent axis: Drag.XAndYAxis } onMouseXChanged: { if (drag.active) { // Update the area width root.width = root.width + mouseX // Ensure we have a minimum width always if (root.width < root.minimumWidth) { root.width = root.minimumWidth } } } onMouseYChanged: { if (drag.active) { // Update the height root.height = root.height + mouseY // Ensure a minimum height if (root.height < root.minimumHeight) { root.height = root.minimumHeight } } } onReleased: { root.resized(root.width, root.height) } } } // Resize: right side Rectangle { width: 4 height: nodeContent.height color: baseColor opacity: 0 anchors.horizontalCenter: parent.right // This mouse area serves as the dragging rectangle MouseArea { id: rightDragger cursorShape: Qt.SizeHorCursor anchors.fill: parent drag { target: parent axis: Drag.XAxis } onMouseXChanged: { if (drag.active) { // Update the area width root.width = root.width + mouseX // Ensure we have a minimum width always if (root.width < root.minimumWidth) { root.width = root.minimumWidth } } } onReleased: { root.resized(root.width, nodeContent.height) } } } // Resize: left side Rectangle { width: 4 height: nodeContent.height color: baseColor opacity: 0 anchors.horizontalCenter: parent.left // This mouse area serves as the dragging rectangle MouseArea { id: leftDragger cursorShape: Qt.SizeHorCursor anchors.fill: parent drag { target: parent axis: Drag.XAxis } onMouseXChanged: { if (drag.active) { // Width of the Area let w = 0 // Update the area width w = root.width - mouseX // Ensure we have a minimum width always if (w > root.minimumWidth) { // Update the node's x position and the width root.x = root.x + mouseX root.width = w } } } onReleased: { // Dragging from the left moves the node as well root.resizedAndMoved(root.width, root.height, Qt.point(root.x, root.y)) } } } // Resize: bottom Rectangle { width: mouseArea.width height: 4 color: baseColor opacity: 0 anchors.verticalCenter: nodeContent.bottom MouseArea { id: bottomDragger cursorShape: Qt.SizeVerCursor anchors.fill: parent drag { target: parent axis: Drag.YAxis } onMouseYChanged: { if (drag.active) { // Update the height root.height = root.height + mouseY // Ensure a minimum height if (root.height < root.minimumHeight) { root.height = root.minimumHeight } } } onReleased: { root.resized(mouseArea.width, root.height) } } } // Resize: top Rectangle { width: mouseArea.width height: 4 color: baseColor opacity: 0 anchors.verticalCenter: parent.top MouseArea { id: topDragger cursorShape: Qt.SizeVerCursor anchors.fill: parent drag { target: parent axis: Drag.YAxis } onMouseYChanged: { if (drag.active) { let h = root.height - mouseY // Ensure a minimum height if (h > root.minimumHeight) { // Update the node's y position and the height root.y = root.y + mouseY root.height = h } } } onReleased: { // Dragging from the top moves the node as well root.resizedAndMoved(root.width, root.height, Qt.point(root.x, root.y)) } } } // Selection border Rectangle { anchors.fill: nodeContent anchors.margins: -border.width visible: root.mainSelected || root.hovered || root.selected border.width: { if (root.mainSelected) return 3 if (root.selected) return 2.5 return 2 } border.color: { if (root.mainSelected) return activePalette.highlight if (root.selected) return Qt.darker(activePalette.highlight, 1.2) return Qt.lighter(activePalette.base, 3) } opacity: 0.9 radius: background.radius + border.width color: "transparent" } Rectangle { id: background anchors.fill: nodeContent color: Qt.darker(baseColor, 1.2) layer.enabled: true layer.effect: DropShadow { radius: 3; color: shadowColor } radius: 3 opacity: 0.7 } Rectangle { id: nodeContent width: parent.width height: parent.height color: "transparent" // Data Layout Column { id: body width: parent.width // Header Rectangle { id: header width: parent.width height: headerLayout.height color: root.baseColor radius: background.radius // Fill header's bottom radius Rectangle { width: parent.width height: parent.radius anchors.bottom: parent.bottom color: parent.color z: -1 } // Header Layout RowLayout { id: headerLayout width: parent.width spacing: 0 // Node Name Label { id: nodeLabel Layout.fillWidth: true text: node ? node.label : "" padding: 4 color: "#2b2b2b" elide: Text.ElideMiddle font.pointSize: 8 } } // Header-only MouseArea: handles drag, click, and selection. // Only the titlebar allows moving the backdrop to preserve standard // rectangle selection behavior on the backdrop body. MouseArea { id: headerMouseArea anchors.fill: parent drag.target: ctrlHeld ? undefined : root // Small drag threshold to avoid moving the node by mistake drag.threshold: 2 hoverEnabled: true acceptedButtons: Qt.LeftButton | Qt.RightButton onPressed: (mouse) => root.pressed(mouse) onReleased: (mouse) => root.released(mouse) onClicked: (mouse) => root.clicked(mouse) onDoubleClicked: (mouse) => root.doubleClicked(mouse) drag.onActiveChanged: { if (!drag.active) { root.moved(Qt.point(root.x, root.y)) } } cursorShape: drag.active ? Qt.ClosedHandCursor : Qt.OpenHandCursor } } // Vertical Spacer Item { width: parent.width height: 2 } // Node Comments Text which is visible on the backdrop Text { visible: node.comment text: node.comment font.pointSize: node.fontSize color: node.fontColor === "" ? "#000000" : node.fontColor y: header.height padding: 4 width: parent.width height: nodeContent.height - header.height wrapMode: Text.Wrap elide: Text.ElideRight } } } } } ================================================ FILE: meshroom/ui/qml/GraphEditor/ChunksListView.qml ================================================ import QtQuick import QtQuick.Controls import QtQuick.Layouts import Utils 1.0 /** * ChunksListView */ ColumnLayout { id: root property var uigraph: null property variant chunks property int currentIndex: 0 property variant currentChunk: (chunks && currentIndex >= 0) ? chunks.at(currentIndex) : undefined onChunksChanged: { // When the list changes, ensure the current index is in the new range if (!chunks) currentIndex = -1 else if (currentIndex >= chunks.count) currentIndex = chunks.count-1 } // chunksSummary is in sync with allChunks button (but not directly accessible as it is in a Component) property bool chunksSummary: (currentIndex === -1) width: 60 ListView { id: chunksLV Layout.fillWidth: true Layout.fillHeight: true model: root.chunks highlightFollowsCurrentItem: (root.chunksSummary === false) keyNavigationEnabled: true focus: true currentIndex: root.currentIndex onCurrentIndexChanged: { if (chunksLV.currentIndex !== root.currentIndex) { // When the list is resized, the currentIndex is reset to 0. // So here we force it to keep the binding. chunksLV.currentIndex = Qt.binding(function() { return root.currentIndex }) } } header: Component { Button { id: allChunks text: "Chunks" width: parent.width flat: true checkable: true property bool summaryEnabled: root.chunksSummary checked: summaryEnabled onSummaryEnabledChanged: { checked = summaryEnabled } onClicked: { root.currentIndex = -1 checked = true } } } highlight: Component { Rectangle { visible: true // !root.chunksSummary color: activePalette.highlight opacity: 0.3 z: 2 } } highlightMoveDuration: 0 highlightResizeDuration: 0 delegate: ItemDelegate { id: chunkDelegate property var chunk: object text: index width: ListView.view.width leftPadding: 8 onClicked: { chunksLV.forceActiveFocus() root.currentIndex = index } Rectangle { width: 4 height: parent.height color: Colors.getChunkColor(parent.chunk) } } } Connections { target: _currentScene function onSelectedChunkChanged() { for (var i = 0; i < root.chunks.count; i++) { if (_currentScene.selectedChunk === root.chunks.at(i)) { root.currentIndex = i break; } } } ignoreUnknownSignals: true } } ================================================ FILE: meshroom/ui/qml/GraphEditor/CompatibilityBadge.qml ================================================ import QtQuick import QtQuick.Controls import QtQuick.Layouts import MaterialIcons 2.2 /** * Node Badge to inform about compatibility issues * Provides 2 delegates (can be set using sourceComponent property): * - iconDelegate (default): icon + tooltip with information about the issue * - bannerDelegate: banner with issue info + upgrade request button */ Loader { id: root property bool canUpgrade property string issueDetails property color color: canUpgrade ? "#E68A00" : "#F44336" signal upgradeRequest() sourceComponent: iconDelegate property Component iconDelegate: Component { Label { text: MaterialIcons.warning font.family: MaterialIcons.fontFamily font.pointSize: 12 color: root.color MouseArea { anchors.fill: parent hoverEnabled: true onPressed: mouse.accepted = false ToolTip.text: issueDetails ToolTip.visible: containsMouse } } } property Component bannerDelegate: Component { Pane { padding: 6 clip: true background: Rectangle { color: root.color } RowLayout { width: parent.width Column { Layout.fillWidth: true Label { width: parent.width elide: Label.ElideMiddle font.bold: true text: "Compatibility issue" color: "white" } Label { width: parent.width elide: Label.ElideMiddle text: root.issueDetails color: "white" } } Button { visible: root.canUpgrade && (parent.width > width) ? 1 : 0 palette.window: root.color palette.button: Qt.darker(root.color, 1.2) palette.buttonText: "white" text: "Upgrade" onClicked: upgradeRequest() } } } } } ================================================ FILE: meshroom/ui/qml/GraphEditor/CompatibilityManager.qml ================================================ import QtQuick import QtQuick.Controls import QtQuick.Layouts import MaterialIcons 2.2 import Controls 1.0 import Utils 1.0 /** * CompatibilityManager summarizes and allows to resolve compatibility issues. */ MessageDialog { id: root // The UIGraph instance property var uigraph // Alias to underlying compatibilityNodes model readonly property var nodesModel: uigraph ? uigraph.graph.compatibilityNodes : undefined // The total number of compatibility issues readonly property int issueCount: (nodesModel !== undefined && nodesModel !== null) ? nodesModel.count : 0 // The number of CompatibilityNodes that can be upgraded readonly property int upgradableCount: { var count = 0 for (var i = 0; i < issueCount; ++i) { if (nodesModel.at(i).canUpgrade) count++; } return count } // Override MessageDialog.getAsString to add compatibility report function getAsString() { var t = asString + "\n" t += '-------------------------\n' t += "Node | Issue | Upgradable\n" t += '-------------------------\n' for (var i = 0; i < issueCount; ++i) { var n = nodesModel.at(i) t += n.nodeType + " | " + n.issueDetails + " | " + n.canUpgrade + "\n" } t += "\n" + questionLabel.text return t } signal upgradeDone() title: "Compatibility issues detected" text: "This project contains " + issueCount + " node(s) incompatible with the current version of Meshroom." detailedText: { let releaseVersion = uigraph ? uigraph.graph.fileReleaseVersion : "0.0" return "Project was created with Meshroom " + releaseVersion + "." } helperText: upgradableCount ? upgradableCount + " node(s) can be upgraded but this might invalidate already computed data.\n" + "This operation is undoable and can also be done manually in the Graph Editor." : "" content: ColumnLayout { spacing: 16 ListView { id: listView Layout.fillWidth: true Layout.maximumHeight: 300 implicitHeight: contentHeight clip: true model: nodesModel property int longestLabel: { var longest = 0 for (var i = 0; i < issueCount; ++i) { var n = nodesModel.at(i) if (n.defaultLabel.length > longest) longest = n.defaultLabel.length } return longest } property int upgradableLabelWidth: { return "Upgradable".length * root.textMetrics.width } ScrollBar.vertical: MScrollBar { id: scrollbar } spacing: 4 headerPositioning: ListView.OverlayHeader header: Pane { z: 2 width: ListView.view.width padding: 6 background: Rectangle { color: Qt.darker(parent.palette.window, 1.15) } RowLayout { width: parent.width Label { text: "Node"; Layout.preferredWidth: listView.longestLabel * root.textMetrics.width; font.bold: true } Label { text: "Issue"; Layout.fillWidth: true; font.bold: true } Label { text: "Upgradable"; Layout.preferredWidth: listView.upgradableLabelWidth; font.bold: true } } } delegate: RowLayout { id: compatibilityNodeDelegate property var node: object width: ListView.view.width - 12 anchors.horizontalCenter: parent != null ? parent.horizontalCenter : undefined Label { Layout.preferredWidth: listView.longestLabel * root.textMetrics.width text: compatibilityNodeDelegate.node ? compatibilityNodeDelegate.node.defaultLabel : "" } Label { Layout.fillWidth: true text: compatibilityNodeDelegate.node ? compatibilityNodeDelegate.node.issueDetails : "" } Label { Layout.preferredWidth: listView.upgradableLabelWidth horizontalAlignment: Text.AlignHCenter text: compatibilityNodeDelegate.node && compatibilityNodeDelegate.node.canUpgrade ? MaterialIcons.check : MaterialIcons.clear color: compatibilityNodeDelegate.node && compatibilityNodeDelegate.node.canUpgrade ? "#4CAF50" : "#F44336" font.family: MaterialIcons.fontFamily font.pointSize: 14 font.bold: true } } } Label { id: questionLabel text: upgradableCount ? "Upgrade all possible nodes to current version?" : "Those nodes cannot be upgraded, remove them manually if needed." } } standardButtons: upgradableCount ? Dialog.Yes | Dialog.No : Dialog.Ok icon { text: MaterialIcons.warning color: "#FF9800" } onAccepted: { if (upgradableCount) { uigraph.upgradeAllNodes() upgradeDone() } } } ================================================ FILE: meshroom/ui/qml/GraphEditor/Edge.qml ================================================ import QtQuick import QtQuick.Controls import QtQuick.Shapes 1.6 import GraphEditor 1.0 import MaterialIcons 2.2 /** * A cubic spline representing an edge, going from point1 to point2, providing mouse interaction. */ Item { id: root property var edge property real point1x property real point1y property real point2x property real point2y property alias thickness: path.strokeWidth property alias color: path.strokeColor property bool isForLoop: false property int loopSize: 0 property int iteration: 0 // Note: edgeArea is destroyed before path, so we need to test if not null to avoid warnings. readonly property bool containsMouse: (loopArea && loopArea.containsMouse) || (edgeArea && edgeArea.containsMouse) signal pressed(var event) signal released(var event) x: point1x y: point1y width: point2x - point1x height: point2y - point1y property real startX: 0 property real startY: 0 property real endX: width property real endY: height function intersectsSegment(p1, p2) { /** * Detects whether a line along the given rects diagonal intersects with the edge mouse area. */ // The edgeArea is within the parent Item and its bounds and position are relative to its parent // Map the original rect to the coordinates of the edgeArea by subtracting the parent's coordinates from the rect // This mapped rect would ensure that the rect coordinates map to 0 of the edge area return edgeArea.intersectsSegment(Qt.point(p1.x - x, p1.y - y), Qt.point(p2.x - x, p2.y - y)); } Shape { anchors.fill: parent // Cause rendering artifacts when enabled (and do not support hot reload really well) vendorExtensionsEnabled: false opacity: 0.7 ShapePath { id: path startX: root.startX startY: root.startY fillColor: "transparent" strokeColor: "#3E3E3E" strokeStyle: edge !== undefined && ((edge.src !== undefined && edge.src.isOutput) || edge.dst === undefined) ? ShapePath.SolidLine : ShapePath.DashLine strokeWidth: 1 // Final visual width of this path (never below 1) readonly property real visualWidth: Math.max(strokeWidth, 1) dashPattern: [6 / visualWidth, 4 / visualWidth] capStyle: ShapePath.RoundCap PathCubic { id: cubic property real ctrlPtDist: 30 x: root.isForLoop ? (root.startX + root.endX) / 2 - loopArea.width / 2 : root.endX y: root.isForLoop ? (root.startY + root.endY) / 2 : root.endY relativeControl1X: ctrlPtDist relativeControl1Y: 0 control2X: x - ctrlPtDist control2Y: y } } ShapePath { id: pathSecondary startX: (root.startX + root.endX) / 2 + loopArea.width / 2 startY: (root.startY + root.endY) / 2 fillColor: "transparent" strokeColor: root.isForLoop ? root.color : "transparent" strokeStyle: edge !== undefined && ((edge.src !== undefined && edge.src.isOutput) || edge.dst === undefined) ? ShapePath.SolidLine : ShapePath.DashLine strokeWidth: root.thickness // Final visual width of this path (never below 1) readonly property real visualWidth: Math.max(strokeWidth, 1) dashPattern: [6 / visualWidth, 4 / visualWidth] capStyle: ShapePath.RoundCap PathCubic { id: cubicSecondary property real ctrlPtDist: 30 x: root.endX y: root.endY relativeControl1X: ctrlPtDist relativeControl1Y: 0 control2X: x - ctrlPtDist control2Y: y } } } Item { // Place the label at the middle of the edge x: (root.startX + root.endX) / 2 y: (root.startY + root.endY) / 2 visible: root.isForLoop Rectangle { anchors.centerIn: parent property int margin: 2 width: icon.width + 2 * margin height: icon.height + 2 * margin radius: width color: path.strokeColor MaterialToolLabel { id: icon anchors.centerIn: parent iconText: MaterialIcons.loop label.text: (root.iteration + 1) + "/" + root.loopSize + " " labelIconColor: palette.base ToolTip.text: "Foreach Loop" } MouseArea { id: loopArea anchors.fill: parent hoverEnabled: true onClicked: root.pressed(arguments[0]) } } } EdgeMouseArea { id: edgeArea anchors.fill: parent acceptedButtons: Qt.LeftButton | Qt.RightButton thickness: root.thickness + 4 curveScale: cubic.ctrlPtDist / root.width // Normalize by width onPressed: function(event) { root.pressed(event) } onReleased: function(event) { root.released(event) } } } ================================================ FILE: meshroom/ui/qml/GraphEditor/GraphEditor.qml ================================================ import QtQuick import QtQuick.Controls import QtQuick.Layouts import Controls 1.0 import MaterialIcons 2.2 import Utils 1.0 /** * A component displaying a Graph (nodes, attributes and edges). */ Item { id: root property variant uigraph: null /// Meshroom UI graph (UIGraph) readonly property variant graph: uigraph ? uigraph.graph : null /// Core graph contained in the UI graph property variant nodeTypesModel: null /// The list of node types that can be instantiated property real maxZoom: 2.0 property real minZoom: 0.1 property var edgeAboutToBeRemoved: undefined property var _attributeToDelegate: ({}) // Signals signal workspaceMoved() signal workspaceClicked() signal nodeDoubleClicked(var mouse, var node) signal computeRequest(var nodes) signal submitRequest(var nodes) property int nbMeshroomScenes: 0 property int nbDraggedFiles: 0 signal filesDropped(var drop, var mousePosition) // Files have been dropped // Trigger initial fit() after initialization // (ensure GraphEditor has its final size) Component.onCompleted: firstFitTimer.start() Timer { id: firstFitTimer running: false interval: 10 onTriggered: fit() } clip: true SystemPalette { id: activePalette } /// Get node delegate for the given node object function nodeDelegate(node) { for(var i = 0; i < nodeRepeater.count; ++i) { if (nodeRepeater.getItemAt(i).node === node) return nodeRepeater.getItemAt(i) } return undefined } /// Duplicate a node and optionally all the following ones function duplicateNode(duplicateFollowingNodes) { var nodes if (duplicateFollowingNodes) { nodes = uigraph.duplicateNodesFrom(uigraph.getSelectedNodes()) } else { nodes = uigraph.duplicateNodes(uigraph.getSelectedNodes()) } uigraph.selectedNode = nodes[0] uigraph.selectNodes(nodes) } /// Copy node content to clipboard function copyNodes() { var nodeContent = uigraph.getSelectedNodesContent() if (nodeContent !== '') { Clipboard.clear() Clipboard.setText(nodeContent) } } /// Paste content of clipboard to graph editor and create new node if valid function pasteNodes() { let finalPosition = undefined if (mouseArea.containsMouse) { finalPosition = mapToItem(draggable, mouseArea.mouseX, mouseArea.mouseY) } else { finalPosition = getCenterPosition() } const copiedContent = Clipboard.getText() const nodes = uigraph.pasteNodes(copiedContent, finalPosition) if (nodes.length > 0) { uigraph.selectedNode = nodes[0] uigraph.selectNodes(nodes) } } /// Get the coordinates of the point at the center of the GraphEditor function getCenterPosition() { return mapToItem(draggable, mouseArea.width / 2, mouseArea.height / 2) } Keys.onPressed: function(event) { if (event.key === Qt.Key_F) { fit() } else if (event.key === Qt.Key_Delete) { if (event.modifiers === Qt.AltModifier) { uigraph.removeNodesFrom(uigraph.getSelectedNodes()) } else { uigraph.removeSelectedNodes() } } else if (event.key === Qt.Key_D) { duplicateNode(event.modifiers === Qt.AltModifier) } else if (event.key === Qt.Key_X) { if (event.modifiers === Qt.ControlModifier) { copyNodes() uigraph.removeSelectedNodes() } else { uigraph.disconnectSelectedNodes() } } else if (event.key === Qt.Key_C) { if (event.modifiers === Qt.ControlModifier) { copyNodes() } else { colorSelector.toggle() } } else if (event.key === Qt.Key_V && event.modifiers === Qt.ControlModifier) { pasteNodes() } else if (event.key === Qt.Key_V && event.modifiers === Qt.ShiftModifier) { uigraph.alignVertically() } else if (event.key === Qt.Key_H && event.modifiers === Qt.ShiftModifier) { uigraph.alignHorizontally() } else if (event.key === Qt.Key_Tab) { event.accepted = true if (mouseArea.containsMouse) { newNodeMenu.spawnPosition = mouseArea.mapToItem(draggable, mouseArea.mouseX, mouseArea.mouseY) newNodeMenu.popup() } } } MouseArea { id: mouseArea anchors.fill: parent property double factor: 1.15 property bool removingEdges: false // Activate multisampling for edges antialiasing layer.enabled: true layer.samples: 8 hoverEnabled: true acceptedButtons: Qt.LeftButton | Qt.RightButton | Qt.MiddleButton drag.threshold: 0 drag.smoothed: false cursorShape: drag.target == draggable ? Qt.ClosedHandCursor : removingEdges ? Qt.CrossCursor : Qt.ArrowCursor onWheel: function(wheel) { var zoomFactor = wheel.angleDelta.y > 0 ? factor : 1 / factor var scale = draggable.scale * zoomFactor scale = Math.min(Math.max(minZoom, scale), maxZoom) if (draggable.scale == scale) return var point = mapToItem(draggable, wheel.x, wheel.y) draggable.x += (1 - zoomFactor) * point.x * draggable.scale draggable.y += (1 - zoomFactor) * point.y * draggable.scale draggable.scale = scale workspaceMoved() } onPressed: function(mouse) { if (mouse.button != Qt.MiddleButton && mouse.modifiers == Qt.NoModifier) { uigraph.clearNodeSelection() } if (mouse.button == Qt.LeftButton && (mouse.modifiers == Qt.NoModifier || mouse.modifiers & (Qt.ControlModifier | Qt.ShiftModifier))) { nodeSelectionBox.startSelection(mouse) } if (mouse.button == Qt.MiddleButton || (mouse.button == Qt.LeftButton && mouse.modifiers & Qt.AltModifier)) { drag.target = draggable // start drag } if (mouse.button == Qt.LeftButton && (mouse.modifiers & Qt.ControlModifier) && (mouse.modifiers & Qt.AltModifier)) { edgeSelectionLine.startSelection(mouse) removingEdges = true } } onReleased: { removingEdges = false edgeSelectionLine.endSelection() nodeSelectionBox.endSelection() drag.target = null root.forceActiveFocus() workspaceClicked() } onPositionChanged: { if (drag.active) workspaceMoved() } onClicked: function(mouse) { if (mouse.button == Qt.RightButton) { // Store mouse click position in 'draggable' coordinates as new node spawn position newNodeMenu.spawnPosition = mouseArea.mapToItem(draggable, mouse.x, mouse.y) newNodeMenu.popup() } } // Contextual Menu for creating new nodes // TODO: add filtering + validate on 'Enter' Menu { id: newNodeMenu property point spawnPosition property variant menuKeys: Object.keys(root.nodeTypesModel).concat(Object.values(MeshroomApp.pipelineTemplateNames)) height: searchBar.height + nodeMenuRepeater.height + instantiator.height function createNode(nodeType) { // "nodeType" might be a pipeline (artificially added in the "Pipelines" category) instead of a node // If it is not a pipeline to import, then it must be a node if (!importPipeline(nodeType)) { // Add node via the proper command in uigraph var node = uigraph.addNewNode(nodeType, spawnPosition) uigraph.selectedNode = node uigraph.selectNodes([node]) } close() } function importPipeline(pipeline) { if (MeshroomApp.pipelineTemplateNames.includes(pipeline)) { var url = MeshroomApp.pipelineTemplateFiles[MeshroomApp.pipelineTemplateNames.indexOf(pipeline)]["path"] var nodes = uigraph.importProject(Filepath.stringToUrl(url), spawnPosition) uigraph.selectedNode = nodes[0] uigraph.selectNodes(nodes) return true } return false } function parseCategories() { // Organize nodes based on their category // {"category1": ["node1", "node2"], "category2": ["node3", "node4"]} let categories = {} for (const [name, data] of Object.entries(root.nodeTypesModel)) { let category = data["category"] if (categories[category] === undefined) { categories[category] = [] } categories[category].push(name) } // Add a "Pipelines" category, filled with the list of templates to create pipelines from the menu if (MeshroomApp.pipelineTemplateNames.length > 0) { categories["Pipelines"] = MeshroomApp.pipelineTemplateNames } return categories } onVisibleChanged: { searchBar.clear() if (visible) { // When menu is shown, give focus to the TextField filter searchBar.forceActiveFocus() } } SearchBar { id: searchBar width: parent.width } // menuItemDelegate is wrapped in a component so it can be used in both the search bar and sub-menus Component { id: menuItemDelegateComponent MenuItem { id: menuItemDelegate font.pointSize: 8 padding: 3 // Hide items that does not match the filter text visible: modelData.toLowerCase().indexOf(searchBar.text.toLowerCase()) > -1 text: modelData // Forward key events to the search bar to continue typing seamlessly // even if this delegate took the activeFocus due to mouse hovering Keys.forwardTo: [searchBar.textField] Keys.onPressed: function(event) { event.accepted = false switch (event.key) { case Qt.Key_Return: case Qt.Key_Enter: // Create node on validation (Enter/Return keys) newNodeMenu.createNode(modelData) event.accepted = true break case Qt.Key_Up: case Qt.Key_Down: case Qt.Key_Left: case Qt.Key_Right: break // Ignore if arrow key was pressed to let the menu be controlled default: searchBar.forceActiveFocus() } } // Set the priority ordering of the keys to be Item's own Key Handling > ForwardTo Keys.priority: Keys.AfterItem // Create node on mouse click onClicked: newNodeMenu.createNode(modelData) states: [ State { // Additional property setting when the MenuItem is not visible when: !visible name: "invisible" PropertyChanges { target: menuItemDelegate height: 0 // Make sure the item is no visible by setting height to 0 focusPolicy: Qt.NoFocus // Don't grab focus when not visible } } ] } } Repeater { id: nodeMenuRepeater model: searchBar.text !== "" ? Object.values(newNodeMenu.menuKeys) : undefined // Create Menu items from available items delegate: menuItemDelegateComponent } // Dynamically add the menu categories Instantiator { id: instantiator model: (searchBar.text === "") ? Object.keys(newNodeMenu.parseCategories()).sort() : undefined onObjectAdded: function(index, object) { // Add sub-menu under the search bar newNodeMenu.insertMenu(index + 1, object) } onObjectRemoved: function(index, object) { newNodeMenu.removeMenu(object) } delegate: Menu { title: modelData id: newNodeSubMenu Instantiator { model: newNodeMenu.visible ? newNodeMenu.parseCategories()[modelData] : undefined onObjectAdded: function(index, object) { newNodeSubMenu.insertItem(index, object) } onObjectRemoved: function(index, object) { newNodeSubMenu.removeItem(object) } delegate: menuItemDelegateComponent } } } } // Informative contextual menu when graph is read-only Menu { id: lockedMenu MenuItem { id: item font.pointSize: 8 enabled: false text: "Computing - Graph is Locked!" } } Item { id: draggable transformOrigin: Item.TopLeft width: 1000 height: 1000 Popup { id: edgeMenu property var currentEdge: null property bool forLoop: false onOpened: { expandButton.canExpand = uigraph.canExpandForLoop(edgeMenu.currentEdge) } contentItem: Row { IntSelector { id: loopIterationSelector tooltipText: "Iterations" visible: edgeMenu.currentEdge && edgeMenu.forLoop enabled: expandButton.canExpand property var listAttr: edgeMenu.currentEdge ? edgeMenu.currentEdge.src.root : null Connections { target: edgeMenu function onCurrentEdgeChanged() { if (edgeMenu.currentEdge) { loopIterationSelector.listAttr = edgeMenu.currentEdge.src.root loopIterationSelector.value = loopIterationSelector.listAttr ? loopIterationSelector.listAttr.value.indexOf(edgeMenu.currentEdge.src) + 1 : 0 } } } // We add 1 to the index because of human readable index (starting at 1) value: listAttr ? listAttr.value.indexOf(edgeMenu.currentEdge.src) + 1 : 0 range: { "min": 1, "max": listAttr ? listAttr.value.count : 0 } onValueChanged: { if (listAttr === null) { return } const newSrcAttr = listAttr.value.at(value - 1) const dst = edgeMenu.currentEdge.dst // If the edge exists, do not replace it if (newSrcAttr === edgeMenu.currentEdge.src && dst === edgeMenu.currentEdge.dst) { return } edgeMenu.currentEdge = uigraph.replaceEdge(edgeMenu.currentEdge, newSrcAttr, dst) } } MaterialToolButton { font.pointSize: 13 ToolTip.text: "Remove Edge" enabled: edgeMenu.currentEdge && !edgeMenu.currentEdge.dst.node.locked && !edgeMenu.currentEdge.dst.isReadOnly text: MaterialIcons.delete_ onClicked: { uigraph.removeEdge(edgeMenu.currentEdge) edgeMenu.close() } } MaterialToolButton { id: expandButton property bool canExpand: edgeMenu.currentEdge && edgeMenu.forLoop visible: edgeMenu.currentEdge && edgeMenu.forLoop && canExpand enabled: edgeMenu.currentEdge && !edgeMenu.currentEdge.dst.node.locked && !edgeMenu.currentEdge.dst.isReadOnly font.pointSize: 13 ToolTip.text: "Expand" text: MaterialIcons.open_in_full onClicked: { edgeMenu.currentEdge = uigraph.expandForLoop(edgeMenu.currentEdge) canExpand = false edgeMenu.close() } } MaterialToolButton { id: collapseButton visible: edgeMenu.currentEdge && edgeMenu.forLoop && !expandButton.canExpand enabled: edgeMenu.currentEdge && !edgeMenu.currentEdge.dst.node.locked && !edgeMenu.currentEdge.dst.isReadOnly font.pointSize: 13 ToolTip.text: "Collapse" text: MaterialIcons.close_fullscreen onClicked: { uigraph.collapseForLoop(edgeMenu.currentEdge) expandButton.canExpand = true edgeMenu.close() } } } } // Edges Repeater { id: edgesRepeater // Delay edges loading after nodes (edges needs attribute pins to be created) model: nodeRepeater.loaded && root.graph ? root.graph.edges : undefined delegate: Edge { function getAttributePin(attribute) { // Get the first visible parent of "attribute" let dstAttributeDelegate = root._attributeToDelegate[attribute] if (dstAttributeDelegate && dstAttributeDelegate.visible) { return dstAttributeDelegate } if (!attribute || !attribute.root) { return null } let index = Array.from(attribute.root.value).indexOf(attribute) let groupAttributeDelegate = null let groupAttribute = attribute while (groupAttribute && (!groupAttributeDelegate || (groupAttributeDelegate && !groupAttributeDelegate.visible && groupAttribute && groupAttribute.root))) { groupAttribute = groupAttribute ? groupAttribute.root : null if (groupAttribute) { groupAttributeDelegate = root._attributeToDelegate[groupAttribute] } } if (groupAttributeDelegate) { return groupAttributeDelegate } return dstAttributeDelegate } property var src: getAttributePin(edge.src) property var dst: getAttributePin(edge.dst) property bool isValidEdge: src !== null && dst !== null visible: isValidEdge && src.visible && dst.visible property bool forLoop: { if (src !== null && dst !== null) { return src.attribute.type === "ListAttribute" && dst.attribute.type != "ListAttribute" } return false } property bool inFocus: containsMouse || (edgeMenu.opened && edgeMenu.currentEdge === edge) edge: object isForLoop: forLoop loopSize: forLoop ? edge.src.root.value.count : 0 iteration: forLoop ? edge.src.root.value.indexOf(edge.src) : 0 color: edge.dst === root.edgeAboutToBeRemoved ? "red" : inFocus ? activePalette.highlight : activePalette.text thickness: { if (forLoop) { return (inFocus) ? 4 : 3 } return (inFocus) ? 2 : 1 } point1x: isValidEdge ? src.globalX + src.outputAnchorPos.x : 0 point1y: isValidEdge ? src.globalY + src.outputAnchorPos.y : 0 point2x: isValidEdge ? dst.globalX + dst.inputAnchorPos.x : 0 point2y: isValidEdge ? dst.globalY + dst.inputAnchorPos.y : 0 onPressed: function(event) { const canEdit = !edge.dst.node.locked if (event.button) { if (canEdit && (event.modifiers & Qt.AltModifier)) { uigraph.removeEdge(edge) } else if (event.button == Qt.RightButton) { edgeMenu.currentEdge = edge edgeMenu.forLoop = forLoop var spawnPosition = mouseArea.mapToItem(draggable, mouseArea.mouseX, mouseArea.mouseY) edgeMenu.x = spawnPosition.x edgeMenu.y = spawnPosition.y edgeMenu.open() } } } } } Loader { id: nodeMenuLoader property var currentNode: null active: currentNode != null sourceComponent: nodeMenuComponent function load(node) { currentNode = node } function unload() { currentNode = null } function showDataDeletionDialog(deleteFollowing: bool, callback) { uigraph.forceNodesStatusUpdate() const dialog = deleteDataDialog.createObject( root, { "node": currentNode, "deleteFollowing": deleteFollowing } ) dialog.open() if(callback) dialog.dataDeleted.connect(callback) } } Component { id: nodeMenuComponent Menu { id: nodeMenu property var currentNode: nodeMenuLoader.currentNode // Cache computatibility/submitability status of each selected node. readonly property var nodeSubmitOrComputeStatus: { var collectedStatus = ({}) uigraph.nodeSelection.selectedIndexes.forEach(function(idx) { const node = uigraph.graph.nodes.at(idx.row) collectedStatus[node] = uigraph.graph.canSubmitOrCompute(node) }) return collectedStatus } readonly property bool isSelectionFullyComputed: { return uigraph.nodeSelection.selectedIndexes.every(function(idx) { const node = uigraph.graph.nodes.at(idx.row) return node.isComputed }) } // Selection contains only compatibility nodes readonly property bool isSelectionFullyCompatibility: { return uigraph.nodeSelection.selectedIndexes.every(function(idx) { const node = uigraph.graph.nodes.at(idx.row) return node.isCompatibilityNode }) } // Selection contains at least one computable node type readonly property bool selectionContainsComputableNodeType: { return uigraph.nodeSelection.selectedIndexes.some(function(idx) { const node = uigraph.graph.nodes.at(idx.row) return node.isComputableType }) } readonly property bool canSelectionBeComputed: { if(!selectionContainsComputableNodeType) return false if(isSelectionFullyCompatibility) return false if(isSelectionFullyComputed) return true var b = uigraph.nodeSelection.selectedIndexes.every(function(idx) { const node = uigraph.graph.nodes.at(idx.row) return ( node.isComputed || (uigraph.graph.canComputeTopologically(node) && // canCompute if canSubmitOrCompute == 1(can compute) or 3(can compute & submit) nodeSubmitOrComputeStatus[node] % 2 == 1) ) }) return b } readonly property bool isSelectionSubmitable: uigraph.canSubmit && selectionContainsComputableNodeType readonly property bool canSelectionBeSubmitted: { if(!selectionContainsComputableNodeType) return false if(isSelectionFullyCompatibility) return false if(isSelectionFullyComputed) return true return uigraph.nodeSelection.selectedIndexes.every(function(idx) { const node = uigraph.graph.nodes.at(idx.row) return ( node.isComputed || (uigraph.graph.canComputeTopologically(node) && // canSubmit if canSubmitOrCompute == 2(can submit) or 3(can compute & submit) nodeSubmitOrComputeStatus[node] > 1) ) }) } width: 220 Component.onCompleted: popup() onClosed: nodeMenuLoader.unload() MenuItem { id: computeMenuItem text: nodeMenu.isSelectionFullyComputed ? "Re-Compute" : "Compute" visible: nodeMenu.selectionContainsComputableNodeType height: visible ? implicitHeight : 0 enabled: nodeMenu.canSelectionBeComputed onTriggered: { if (nodeMenu.isSelectionFullyComputed) { nodeMenuLoader.showDataDeletionDialog( false, function(request, uigraph) { request(uigraph.getSelectedNodes()) }.bind(null, computeRequest, uigraph) ) } else { computeRequest(uigraph.getSelectedNodes()) } } } MenuItem { id: submitMenuItem text: nodeMenu.isSelectionFullyComputed ? "Re-Submit" : "Submit" visible: nodeMenu.isSelectionSubmitable height: visible ? implicitHeight : 0 enabled: nodeMenu.canSelectionBeSubmitted onTriggered: { if (nodeMenu.isSelectionFullyComputed) { nodeMenuLoader.showDataDeletionDialog( false, function(request, uigraph) { request(uigraph.getSelectedNodes()) }.bind(null, submitRequest, uigraph) ) } else { submitRequest(uigraph.getSelectedNodes()) } } } MenuItem { text: "Stop Computation" enabled: nodeMenu.currentNode.canBeStopped() && nodeMenu.currentNode.globalExecMode == "LOCAL" visible: enabled height: visible ? implicitHeight : 0 onTriggered: uigraph.stopNodeComputation(nodeMenu.currentNode) } MenuItem { text: "Cancel Computation" enabled: nodeMenu.currentNode.canBeCanceled() && nodeMenu.currentNode.globalExecMode == "LOCAL" visible: enabled height: visible ? implicitHeight : 0 onTriggered: uigraph.cancelNodeComputation(nodeMenu.currentNode) } MenuItem { text: "Interrupt Job" enabled: nodeMenu.currentNode.canBeStopped() && nodeMenu.currentNode.globalExecMode == "EXTERN" visible: enabled height: visible ? implicitHeight : 0 onTriggered: uigraph.stopNode(nodeMenu.currentNode) } MenuItem { text: "Cancel Job" enabled: nodeMenu.currentNode.canBeCanceled() && nodeMenu.currentNode.globalExecMode == "EXTERN" visible: enabled height: visible ? implicitHeight : 0 onTriggered: uigraph.stopNode(nodeMenu.currentNode) } MenuItem { text: "Retry Error Tasks" enabled: nodeMenu.currentNode.globalExecMode == "EXTERN" && ["ERROR", "STOPPED", "KILLED"].includes(nodeMenu.currentNode.globalStatus) visible: enabled height: visible ? implicitHeight : 0 onTriggered: uigraph.restartJobErrorTasks(nodeMenu.currentNode) } MenuItem { text: "Open Folder" visible: nodeMenu.currentNode.isComputableType height: visible ? implicitHeight : 0 onTriggered: Qt.openUrlExternally(Filepath.stringToUrl(nodeMenu.currentNode.internalFolder)) } MenuSeparator { visible: nodeMenu.currentNode.isComputableType } MenuItem { text: "Cut Node(s)" enabled: true ToolTip.text: "Copy selection to the clipboard and remove it" ToolTip.visible: hovered onTriggered: { copyNodes() uigraph.removeSelectedNodes() } } MenuItem { text: "Copy Node(s)" enabled: true ToolTip.text: "Copy selection to the clipboard" ToolTip.visible: hovered onTriggered: copyNodes() } MenuItem { text: "Paste Node(s)" enabled: true ToolTip.text: "Copy selection to the clipboard and immediately paste it" ToolTip.visible: hovered onTriggered: { copyNodes() pasteNodes() } } MenuItem { text: "Disconnect Node(s)" enabled: true ToolTip.text: "Disconnect all edges from the selected Node(s)" ToolTip.visible: hovered onTriggered: uigraph.disconnectSelectedNodes() } MenuItem { text: "Duplicate Node(s)" + (duplicateFollowingButton.hovered ? " From Here" : "") enabled: true onTriggered: duplicateNode(false) MaterialToolButton { id: duplicateFollowingButton height: parent.height anchors { right: parent.right rightMargin: parent.padding } text: MaterialIcons.fast_forward onClicked: { duplicateNode(true) nodeMenu.close() } } } MenuItem { text: "Remove Node(s)" + (removeFollowingButton.hovered ? " From Here" : "") enabled: !nodeMenu.currentNode.locked onTriggered: uigraph.removeSelectedNodes() MaterialToolButton { id: removeFollowingButton height: parent.height anchors { right: parent.right rightMargin: parent.padding } text: MaterialIcons.fast_forward onClicked: { uigraph.removeNodesFrom(uigraph.getSelectedNodes()) nodeMenu.close() } } } MenuSeparator { visible: nodeMenu.currentNode.isComputableType } MenuItem { id: deleteDataMenuItem text: "Delete Data" + (deleteFollowingButton.hovered ? " From Here" : "" ) + "..." visible: nodeMenu.currentNode.isComputableType height: visible ? implicitHeight : 0 enabled: { if (!nodeMenu.currentNode) return false // Check if the current node is locked (needed because it does not belong to its own duplicates list) if (nodeMenu.currentNode.locked) return false // Check if at least one of the duplicate nodes is locked for (let i = 0; i < nodeMenu.currentNode.duplicates.count; ++i) { if (nodeMenu.currentNode.duplicates.at(i).locked) return false } return true } onTriggered: nodeMenuLoader.showDataDeletionDialog(false) MaterialToolButton { id: deleteFollowingButton anchors { right: parent.right rightMargin: parent.padding } height: parent.height text: MaterialIcons.fast_forward onClicked: { nodeMenuLoader.showDataDeletionDialog(true) nodeMenu.close() } } } } } // Confirmation dialog for node cache deletion Component { id: deleteDataDialog MessageDialog { property var node property bool deleteFollowing: false signal dataDeleted() focus: true modal: false header.visible: false text: "Delete Data of '" + node.label + "'" + (uigraph.nodeSelection.selectedIndexes.length > 1 ? " and other selected Nodes" : "") + (deleteFollowing ? " and following Nodes?" : "?") helperText: "Warning: This operation cannot be undone." standardButtons: Dialog.Yes | Dialog.Cancel onAccepted: { if (deleteFollowing) uigraph.clearDataFrom(uigraph.getSelectedNodes()) else uigraph.clearSelectedNodesData() dataDeleted() } onClosed: destroy() } } // Nodes Repeater { id: nodeRepeater model: root.graph ? root.graph.nodes : undefined property bool loaded: model ? count === model.count : false property bool ongoingDrag: false property bool updateSelectionOnClick: false property var temporaryEdgeAboutToBeRemoved: undefined function getItemAt(index) { const loader = itemAt(index) if (loader && loader.item) return loader.item return null } delegate: Loader { id: nodeLoader Component { id: nodeComponent Node { id: nodeDelegate node: object width: uigraph.layout.nodeWidth mainSelected: uigraph.selectedNode === node hovered: uigraph.hoveredNode === node // ItemSelectionModel.hasSelection triggers updates anytime the selectionChanged() signal is emitted. selected: uigraph.nodeSelection.hasSelection ? uigraph.nodeSelection.isRowSelected(index) : false onAttributePinCreated: function(attribute, pin) { registerAttributePin(attribute, pin) } onAttributePinDeleted: function(attribute, pin) { unregisterAttributePin(attribute, pin) } onShaked: { uigraph.disconnectSelectedNodes() } onPressed: function(mouse) { nodeRepeater.updateSelectionOnClick = true nodeRepeater.ongoingDrag = true let selectionMode = ItemSelectionModel.NoUpdate if (!selected) { selectionMode = ItemSelectionModel.ClearAndSelect } if (mouse.button === Qt.LeftButton) { if (mouse.modifiers & Qt.ShiftModifier) { selectionMode = ItemSelectionModel.Select } if (mouse.modifiers & Qt.ControlModifier) { selectionMode = ItemSelectionModel.Toggle } if (mouse.modifiers & Qt.AltModifier) { let selectFollowingMode = ItemSelectionModel.ClearAndSelect if (mouse.modifiers & Qt.ShiftModifier) { selectFollowingMode = ItemSelectionModel.Select } uigraph.selectFollowing(node, selectFollowingMode) // Indicate selection has been dealt with by setting conservative Select mode. selectionMode = ItemSelectionModel.Select } } else if (mouse.button === Qt.RightButton) { if (selected) { // Keep the full selection when right-clicking on an already selected node. nodeRepeater.updateSelectionOnClick = false } } if (selectionMode != ItemSelectionModel.NoUpdate) { nodeRepeater.updateSelectionOnClick = false uigraph.selectNodeByIndex(index, selectionMode) } // If the node is selected after this, make it the active selected node. if (selected) { uigraph.selectedNode = node } // Open the node context menu once selection has been updated. if (mouse.button == Qt.RightButton) { nodeMenuLoader.load(node) } } onReleased: function(mouse, wasDragged) { nodeRepeater.ongoingDrag = false } // Only called when the node has not been dragged. onClicked: function(mouse) { if (!nodeRepeater.updateSelectionOnClick) { return } uigraph.selectNodeByIndex(index) } onDoubleClicked: function(mouse) { root.nodeDoubleClicked(mouse, node) } onEntered: uigraph.hoveredNode = node onExited: uigraph.hoveredNode = null onEdgeAboutToBeRemoved: function(input) { /* * Sometimes the signals are not in the right order because of weird Qt/QML update order * (next DropArea entered signal before previous DropArea exited signal) so edgeAboutToBeRemoved * must be set to undefined before it can be set to another attribute object. */ if (input === undefined) { if (nodeRepeater.temporaryEdgeAboutToBeRemoved === undefined) { root.edgeAboutToBeRemoved = input } else { root.edgeAboutToBeRemoved = nodeRepeater.temporaryEdgeAboutToBeRemoved nodeRepeater.temporaryEdgeAboutToBeRemoved = undefined } } else { if (root.edgeAboutToBeRemoved === undefined) { root.edgeAboutToBeRemoved = input } else { nodeRepeater.temporaryEdgeAboutToBeRemoved = input } } } // Interactive dragging: move the visual delegates onPositionChanged: { if (!selected || !dragging) { return } // Check for shake on the node checkForShake() // Compute offset between the delegate and the stored node position. const offset = Qt.point(x - node.x, y - node.y) uigraph.nodeSelection.selectedIndexes.forEach(function(idx) { if (idx != index) { const delegate = nodeRepeater.getItemAt(idx.row) delegate.x = delegate.node.x + offset.x delegate.y = delegate.node.y + offset.y } }) } // After drag: apply the final offset to all selected nodes onMoved: function(position) { const offset = Qt.point(position.x - node.x, position.y - node.y) uigraph.moveSelectedNodesBy(offset) } Behavior on x { enabled: !nodeRepeater.ongoingDrag NumberAnimation { duration: 100 } } Behavior on y { enabled: !nodeRepeater.ongoingDrag NumberAnimation { duration: 100 } } } } Component { id: backdropComponent Backdrop { id: backdropDelegate node: object modelInstantiator: nodeRepeater mainSelected: uigraph.selectedNode === node hovered: uigraph.hoveredNode === node // ItemSelectionModel.hasSelection triggers updates anytime the selectionChanged() signal is emitted. selected: uigraph.nodeSelection.hasSelection ? uigraph.nodeSelection.isRowSelected(index) : false onPressed: function(mouse) { nodeRepeater.updateSelectionOnClick = true nodeRepeater.ongoingDrag = true ctrlHeld = (mouse.modifiers & Qt.ControlModifier) !== 0 let selectionMode = ItemSelectionModel.NoUpdate if (!selected) { selectionMode = ItemSelectionModel.ClearAndSelect } if (mouse.button === Qt.LeftButton) { if (mouse.modifiers & Qt.ShiftModifier) { selectionMode = ItemSelectionModel.Select } if (mouse.modifiers & Qt.ControlModifier) { selectionMode = ItemSelectionModel.Clear } if (mouse.modifiers & Qt.AltModifier) { let selectFollowingMode = ItemSelectionModel.ClearAndSelect if (mouse.modifiers & Qt.ShiftModifier) { selectFollowingMode = ItemSelectionModel.Select } uigraph.selectFollowing(node, selectFollowingMode) // Indicate selection has been dealt with by setting conservative Select mode. selectionMode = ItemSelectionModel.Select } } else if (mouse.button === Qt.RightButton) { if (selected) { // Keep the full selection when right-clicking on an already selected node. nodeRepeater.updateSelectionOnClick = false } } if (selectionMode != ItemSelectionModel.NoUpdate) { nodeRepeater.updateSelectionOnClick = false uigraph.selectNodeByIndex(index, selectionMode) } // If the node is selected after this, make it the active selected node. if (selected) { uigraph.selectedNode = node } if (!(mouse.modifiers & Qt.AltModifier)) { uigraph.selectNodesByIndices(childrenIndices, ItemSelectionModel.Select) } // Open the node context menu once selection has been updated. if (mouse.button == Qt.RightButton) { nodeMenuLoader.load(node) } } onReleased: function(mouse, wasDragged) { ctrlHeld = false nodeRepeater.ongoingDrag = false } // Only called when the node has not been dragged. onClicked: function(mouse) { if (!nodeRepeater.updateSelectionOnClick) { return } uigraph.selectNodeByIndex(index) if (!(mouse.modifiers & Qt.AltModifier)) { uigraph.selectNodesByIndices(childrenIndices, ItemSelectionModel.Select) } } onDoubleClicked: function(mouse) { root.nodeDoubleClicked(mouse, node) } onResized: function(width, height) { uigraph.resizeNode(node, width, height) } onResizedAndMoved: function(width, height, position) { uigraph.resizeAndMoveNode(node, width, height, position) } onEntered: uigraph.hoveredNode = node onExited: uigraph.hoveredNode = null // Interactive dragging: move the visual delegates onPositionChanged: { if (!selected || !dragging) { return } // Compute offset between the delegate and the stored node position. const offset = Qt.point(x - node.x, y - node.y) uigraph.nodeSelection.selectedIndexes.forEach(function(idx) { if (idx != index) { const delegate = nodeRepeater.getItemAt(idx.row) delegate.x = delegate.node.x + offset.x delegate.y = delegate.node.y + offset.y } }) } // After drag: apply the final offset to all selected nodes onMoved: function(position) { const offset = Qt.point(position.x - node.x, position.y - node.y) uigraph.moveSelectedNodesBy(offset) } Behavior on x { enabled: !nodeRepeater.ongoingDrag && !resizing && !uigraph.animationsDisabled NumberAnimation { duration: 100 } } Behavior on y { enabled: !nodeRepeater.ongoingDrag && !resizing && !uigraph.animationsDisabled NumberAnimation { duration: 100 } } } } sourceComponent: object.isBackdropNode ? backdropComponent : nodeComponent onLoaded: { nodeLoader.z = nodeLoader.item.z } } } } DelegateSelectionBox { id: nodeSelectionBox mouseArea: mouseArea modelInstantiator: nodeRepeater container: draggable onDelegateSelectionEnded: function(selectedIndices, modifiers) { let selectionMode = ItemSelectionModel.ClearAndSelect if(modifiers & Qt.ShiftModifier) { selectionMode = ItemSelectionModel.Select } else if(modifiers & Qt.ControlModifier) { selectionMode = ItemSelectionModel.Deselect } uigraph.selectNodesByIndices(selectedIndices, selectionMode) } } DelegateSelectionLine { id: edgeSelectionLine mouseArea: mouseArea modelInstantiator: edgesRepeater container: draggable onDelegateSelectionEnded: function(selectedIndices, modifiers) { uigraph.deleteEdgesByIndices(selectedIndices) } } DropArea { id: dropArea anchors.fill: parent keys: ["text/uri-list"] onEntered: function(drag) { nbMeshroomScenes = 0 nbDraggedFiles = drag.urls.length drag.urls.forEach(function(file) { if (String(file).endsWith(".mg")) { nbMeshroomScenes++ } }) } onDropped: function(drop) { if (nbMeshroomScenes == nbDraggedFiles || nbMeshroomScenes == 0) { // Retrieve mouse position and convert coordinate system // from pixel values to graph reference system var mousePosition = mapToItem(draggable, drag.x, drag.y) // Send the list of files, // to create the corresponding nodes or open another scene filesDropped(drop, mousePosition) } else { errorDialog.open() } } } } NodeActions { id: nodeActions uigraph: root.uigraph draggable: draggable nodeRepeater: nodeRepeater anchors.fill: parent onComputeRequest: function(node) { root.computeRequest([node]) } onStopComputeRequest: function(node) { if (node.canBeStopped()) { uigraph.stopNodeComputation(node) } else if (node.canBeCanceled()) { uigraph.cancelNodeComputation(node) } } onDeleteDataRequest: function(node) { if (nodeActionsSettings.confirmBeforeDelete) { uigraph.forceNodesStatusUpdate(); const dialog = deleteDataDialog.createObject( root, { "node": node, "deleteFollowing": false } ); dialog.open() } else { uigraph.clearSelectedNodesData() } } onSubmitRequest: function(node) { root.submitRequest([node]) } onStopSubmitRequest: function(node) { if (node.canBeStopped() || node.canBeCanceled()) { uigraph.stopNode(node) } } onRetrySubmitRequest: function(node) { uigraph.restartJobErrorTasks(node) } } MessageDialog { id: errorDialog icon.text: MaterialIcons.error icon.color: "#F44336" title: "Different File Types" text: "Do not mix .mg files and other types of files." standardButtons: Dialog.Ok parent: Overlay.overlay onAccepted: close() } // Toolbar FloatingPane { padding: 2 anchors.bottom: parent.bottom RowLayout { id: navigation spacing: 4 // Default index for search property int currentIndex: -1 // Fit MaterialToolButton { text: MaterialIcons.fullscreen ToolTip.text: "Fit" onClicked: root.fit() } // Auto-Layout MaterialToolButton { text: MaterialIcons.linear_scale ToolTip.text: "Auto-Layout" onClicked: uigraph.layout.reset() } // Add Backdrop MaterialToolButton { text: MaterialIcons.sticky_note_2 ToolTip.text: "Add Backdrop" onClicked: { var selectedNodes = uigraph.getSelectedNodes() var backdrop if (selectedNodes.length > 0) { // Calculate bounding box of selected nodes var padding = uigraph.layout.gridSpacing * 0.5 var minX = Number.MAX_VALUE var minY = Number.MAX_VALUE var maxX = -Number.MAX_VALUE var maxY = -Number.MAX_VALUE for (var i = 0; i < selectedNodes.length; i++) { var n = selectedNodes[i] var nw = n.nodeWidth > 0 ? n.nodeWidth : uigraph.layout.nodeWidth var nh = n.nodeHeight > 0 ? n.nodeHeight : uigraph.layout.nodeHeight minX = Math.min(minX, n.x) minY = Math.min(minY, n.y) maxX = Math.max(maxX, n.x + nw) maxY = Math.max(maxY, n.y + nh) } var bboxX = minX - padding var bboxY = minY - 2 * padding // minus padding and title bar height var bboxW = Math.round(maxX - minX + 2 * padding) var bboxH = Math.round(maxY - minY + 2 * padding) backdrop = uigraph.addBackdropNode(Qt.point(bboxX, bboxY), bboxW, bboxH) } else { backdrop = uigraph.addNewNode("Backdrop", getCenterPosition()) } uigraph.selectedNode = backdrop uigraph.selectNodes([backdrop]) } } // Separator Rectangle { Layout.fillHeight: true Layout.margins: 2 implicitWidth: 1 color: activePalette.window } ColorSelector { id: colorSelector Layout.minimumWidth: colorSelector.width // When a Color is selected onColorSelected: (color)=> { uigraph.setSelectedNodesColor(color) } } // Separator Rectangle { Layout.fillHeight: true Layout.margins: 2 implicitWidth: 1 color: activePalette.window } // Search nodes in the graph SearchBar { id: graphSearchBar toggle: true // enable toggling the actual text field by the search button Layout.minimumWidth: graphSearchBar.width maxWidth: 150 textField.background.opacity: 0.5 textField.onTextChanged: navigation.currentIndex = -1 onAccepted: { navigation.navigateForward() } } MaterialToolButton { text: MaterialIcons.arrow_left padding: 0 visible: graphSearchBar.text !== "" onClicked: navigation.navigateBackward() } MaterialToolButton { id: nextArrow text: MaterialIcons.arrow_right padding: 0 visible: graphSearchBar.text !== "" onClicked: navigation.navigateForward() } Label { id: currentSearchLabel text: " " + (navigation.currentIndex + 1) + "/" + filteredNodes.count padding: 0 visible: graphSearchBar.text !== "" } Repeater { id: filteredNodes model: SortFilterDelegateModel { model: root.graph ? root.graph.nodes : undefined sortRole: "label" filters: [{role: "label", value: graphSearchBar.text}] delegate: Item { visible: false // Hide the items to not affect the layout as the nodes model gets changes property var index_: index } function modelData(item, roleName_) { return item.model.object[roleName_] } } } function navigateForward() { /** * Moves the navigation index forwards and focuses on the next node as per index. */ if (!filteredNodes.count) return navigation.currentIndex++ if (navigation.currentIndex === filteredNodes.count) navigation.currentIndex = 0 navigation.nextItem() } function navigateBackward() { /** * Moves the navigation index backwards and focuses on the previous node as per index. */ if (!filteredNodes.count) return navigation.currentIndex-- if (navigation.currentIndex === -1) navigation.currentIndex = filteredNodes.count - 1 navigation.nextItem() } function nextItem() { // Compute bounding box var node = nodeRepeater.getItemAt(filteredNodes.itemAt(navigation.currentIndex).index_) var bbox = Qt.rect(node.x, node.y, node.width, node.height) // Rescale to fit the bounding box in the view, zoom is limited to prevent huge text draggable.scale = Math.min(Math.min(root.width / bbox.width, root.height / bbox.height),maxZoom) // Recenter draggable.x = bbox.x * draggable.scale * -1 + (root.width - bbox.width * draggable.scale) * 0.5 draggable.y = bbox.y * draggable.scale * -1 + (root.height - bbox.height * draggable.scale) * 0.5 } } } function registerAttributePin(attribute, pin) { root._attributeToDelegate[attribute] = pin } function unregisterAttributePin(attribute, pin) { delete root._attributeToDelegate[attribute] } function boundingBox() { var first = nodeRepeater.getItemAt(0) if (first === null) { return Qt.rect(0, 0, 0, 0) } var bbox = Qt.rect(first.x, first.y, first.x + first.width, first.y + first.height) for (var i = 0; i < root.graph.nodes.count; ++i) { var item = nodeRepeater.getItemAt(i) bbox.x = Math.min(bbox.x, item.x) bbox.y = Math.min(bbox.y, item.y) bbox.width = Math.max(bbox.width, item.x + item.width) bbox.height = Math.max(bbox.height, item.y + item.height) } bbox.width -= bbox.x bbox.height -= bbox.y return bbox } function selectionBoundingBox() { /** * Returns the bounding box considering the nodes which are selected. * The returned bounding box starts from the Minumum x,y position to the * Maximum x,y postion of the selected nodes. */ var firstIdx = uigraph.nodeSelection.selectedIndexes[0] const first = nodeRepeater.getItemAt(firstIdx.row) // Bounding box of the first selected item var bbox = Qt.rect(first.x, first.y, first.x + first.width, first.y + first.height) // Iterate over the remaining items in the selection uigraph.nodeSelection.selectedIndexes.forEach(function(idx) { if(idx != firstIdx) { const item = nodeRepeater.getItemAt(idx.row) bbox.x = Math.min(bbox.x, item.x) bbox.y = Math.min(bbox.y, item.y) bbox.width = Math.max(bbox.width, item.x + item.width) bbox.height = Math.max(bbox.height, item.y + item.height) } }) bbox.width -= bbox.x bbox.height -= bbox.y return bbox } // Fit graph to fill root function fit() { var bbox // Compute bounding box if (uigraph.nodeSelection.hasSelection) { bbox = selectionBoundingBox() } else { bbox = boundingBox() } // Rescale to fit the bounding box in the view, zoom is limited to prevent huge text draggable.scale = Math.min(Math.min(root.width / bbox.width, root.height / bbox.height), maxZoom) // Recenter draggable.x = bbox.x * draggable.scale * -1 + (root.width - bbox.width * draggable.scale) * 0.5 draggable.y = bbox.y * draggable.scale * -1 + (root.height - bbox.height * draggable.scale) * 0.5 } } ================================================ FILE: meshroom/ui/qml/GraphEditor/GraphEditorSettings.qml ================================================ pragma Singleton import QtCore /** * Persistent Settings related to the GraphEditor module. */ Settings { category: 'GraphEditor' property bool showAdvancedAttributes: false property bool showDefaultAttributes: true property bool showModifiedAttributes: true property bool showInputAttributes: true property bool showOutputAttributes: true property bool showLinkAttributes: true property bool showNotLinkAttributes: true property bool lockOnCompute: true } ================================================ FILE: meshroom/ui/qml/GraphEditor/Node.qml ================================================ import QtQuick import QtQuick.Controls import QtQuick.Layouts import QtQuick.Effects import MaterialIcons 2.2 import Utils 1.0 /** * Visual representation of a Graph Node. */ Item { id: root /// The underlying Node object property variant node /// Whether the node can be modified property bool readOnly: node.locked /// Whether the node is in compatibility mode readonly property bool isCompatibilityNode: node ? node.hasOwnProperty("compatibilityIssue") : false /// Mouse related states property bool mainSelected: false property bool selected: false property bool hovered: false property bool dragging: mouseArea.drag.active /// Node label property string nodeLabel: node ? node.label : "" /// Combined x and y property point position: Qt.point(x, y) /// Styling readonly property color defaultColor: isCompatibilityNode ? activePalette.mid : !node.isComputableType ? "#BA3D69" : activePalette.base property color baseColor: defaultColor /// Shake Relevance readonly property double maxAmplitude: 500.0; readonly property int shakeThreshold: 5; property int shakeCounter: 0; property bool shaking: false; property int shakeDetectionInterval: 1000; // 1 Second to complete the shake else the counter is reset property double originalRootX: 0.0; property double originalRootY: 0.0; property int directionX: 0; property int directionY: 0; property point mousePosition: Qt.point(mouseArea.mouseX, mouseArea.mouseY) Item { id: m property bool displayParams: false } // Mouse interaction related signals signal pressed(var mouse) signal released(var mouse) signal clicked(var mouse) signal doubleClicked(var mouse) signal moved(var position) signal shaked() signal entered() signal exited() // Already connected attribute with another edge in DropArea signal edgeAboutToBeRemoved(var input) /// Emitted when child attribute pins are created signal attributePinCreated(var attribute, var pin) /// Emitted when child attribute pins are deleted signal attributePinDeleted(var attribute, var pin) // use node name as object name to simplify debugging objectName: node ? node.name : "" // initialize position with node coordinates x: root.node ? root.node.x : undefined y: root.node ? root.node.y : undefined implicitHeight: childrenRect.height SystemPalette { id: activePalette } Connections { target: root.node // update x,y when node position changes function onPositionChanged() { root.x = root.node.x root.y = root.node.y } function onNameChanged() { // HACK: Make sure when the node name changes the node label is updated root.nodeLabel = "" // Restore binding to root.node.label root.nodeLabel = Qt.binding(function() { return root.node.label; }) } } Timer { id: shakeDetectionTimer; interval: root.shakeDetectionInterval; onTriggered: { if (root.shaking) { root.resetShaking(); } } } function beginShaking() { /** * Sets up the shake related values. * Enables Shake detection. */ root.shaking = true; // Capture the current Root's X and Y to use in detecting the movement of the node around these points root.originalRootX = root.x; root.originalRootY = root.y; } function resetShaking() { /** * Resets the shaking and the variables tracking a shake. */ // Reset the shake counter when shaking has ended root.shakeCounter = 0; // Reset the direction detection root.directionX = 0; root.directionY = 0; } function endShaking() { /** * Resets all values related to shaking. * Ends the shake detection. */ root.shaking = false; root.resetShaking(); } function checkForShake() { /** * Detects a shake if a the node has been moved across the originally captured x and y positions * back and forth a given number of times specified by the amplitude. */ if (!root.shaking) { return; } // This indicates that the shake was either reset or we are starting from scratch if (root.shakeCounter === 0 && !shakeDetectionTimer.running) { shakeDetectionTimer.start(); } const deltaX = root.x - root.originalRootX; const deltaY = root.y - root.originalRootY; // Check if the node has not travelled too much from the original position // If so, stop detecting a shake as that might not be needed if (Math.abs(deltaX) > root.maxAmplitude || Math.abs(deltaY) > root.maxAmplitude) { root.endShaking(); } // This checks the current direction in which the node is travelling // if the node has moved on the left side of the original position -1 // if the node has moved on the right side of the original position +1 // <-- Origin // [Node] | // | [Node] // | --> // If the motion continues to be like this 'threshold' number of times // This will be considered as a shake effect const currentDirectionX = deltaX > 0 ? 1 : -1; const currentDirectionY = deltaY > 0 ? 1 : -1; // Check if we are in the opposite direction of what was the previous direction of the Node // If yes then we are propagating as a shake effect if (currentDirectionX != root.directionX || currentDirectionY != root.directionY) { // One shake cycle is complete, increment the counter root.shakeCounter++; // Update the original direction to be the current one root.directionX = currentDirectionX; root.directionY = currentDirectionY; } // The node has moved in a shake effect to match the threshold and this is causing it to be detected as a shake if (root.shakeCounter > root.shakeThreshold) { root.shaked(); // Reset the counter to detect another shake effect root.resetShaking(); } } function formatInternalAttributesTooltip(invalidation, comment) { /* * Creates a string that contains the invalidation message (if it is not empty) in bold, * followed by the comment message (if it exists) in regular font, separated by an empty * line. * Invalidation and comment messages have their tabs or line returns in plain text format replaced * by their HTML equivalents. */ let str = "" if (invalidation !== "") { let replacedInvalidation = node.invalidation.replace(/\n/g, "
").replace(/\t/g, "    ") str += "" + replacedInvalidation + "" } if (invalidation !== "" && comment !== "") { str += "

" } if (comment !== "") { let replacedComment = node.comment.replace(/\n/g, "
").replace(/\t/g, "    ") str += replacedComment } return str } // Used to generate list of node's label sharing the same uid function generateDuplicateList() { let str = "Shares internal folder (data) with:" for (let i = 0; i < node.duplicates.count; ++i) { if (i % 5 === 0) str += "
" const currentNode = node.duplicates.at(i) if (i === node.duplicates.count - 1) { str += currentNode.nameToLabel(currentNode.name) return str } str += (currentNode.nameToLabel(currentNode.name) + ", ") } return str } function updateChildPin(attribute, parentPins, pin) { /* * Update the pin of a child attribute: if the attribute is enabled and its parent is * a GroupAttribute, the visibility is determined based on the parent pin's "expanded" state, * using the "parentPins" map to access the status. * If the current pin is also a GroupAttribute and is expanded while its newly "visible" state * is false, it is reset. */ if (Boolean(attribute.enabled)) { // If the parent is a GroupAttribute, use the status of the parent's pin to determine visibility // UNLESS the child attribute is already connected with a visible edge if (attribute.root && attribute.root.baseType === "GroupAttribute") { var visible = Boolean(parentPins.get(attribute.root.name)) if (!visible && parentPins.has(attribute.name) && parentPins.get(attribute.name) === true) { parentPins.set(attribute.name, false) pin.expanded = false } return visible } return true } return false } function generateAttributesModel(isOutput, parentPins) { if (!node) { return undefined } const attributes = [] for (let i = 0; i < node.attributes.count; i++) { let attr = node.attributes.at(i) if (attr.isOutput == isOutput) { // Add the attribute to the model attributes.push(attr) if (attr.baseType === "GroupAttribute") { // If it is a GroupAttribute, initialize its pin status parentPins.set(attr.name, false) } // Check and add any child this attribute might have attr.flatStaticChildren.forEach((child) => { attributes.push(child) if (child.baseType === "GroupAttribute") { parentPins.set(child.name, false) } } ) } } return attributes } // Main Layout MouseArea { id: mouseArea width: parent.width height: body.height drag.target: root // Small drag threshold to avoid moving the node by mistake drag.threshold: 2 hoverEnabled: true acceptedButtons: Qt.LeftButton | Qt.RightButton onClicked: (mouse) => root.clicked(mouse) onDoubleClicked: (mouse) => root.doubleClicked(mouse) onEntered: root.entered() onExited: root.exited() drag.onActiveChanged: { if (!drag.active) { root.moved(Qt.point(root.x, root.y)) } } cursorShape: drag.active ? Qt.ClosedHandCursor : Qt.ArrowCursor onPressed: function(mouse) { root.pressed(mouse); // Begin shake detection root.beginShaking(); } onReleased: function(mouse) { root.released(mouse); // End shake detection root.endShaking(); } // Selection border Rectangle { anchors.fill: nodeContent anchors.margins: -border.width visible: root.mainSelected || root.hovered || root.selected border.width: { if(root.mainSelected) return 3 if(root.selected) return 2.5 return 2 } border.color: { if(root.mainSelected) return activePalette.highlight if(root.selected) return Qt.darker(activePalette.highlight, 1.2) return Qt.lighter(activePalette.base, 3) } opacity: 0.9 radius: background.radius + border.width color: "transparent" } Rectangle { id: background anchors.fill: nodeContent color: node.color === "" ? Qt.lighter(activePalette.base, 1.4) : node.color layer.enabled: true layer.effect: MultiEffect { shadowColor: activePalette.shadow // Performance tip: Reduce blurMax (not shadowBlur) to minimize shadow blur. shadowBlur: 1.0 // So we keep shadowBlur at 1.0. shadowEnabled: true blurMax: 4 // large values could impact performances } radius: 3 opacity: 0.85 } Rectangle { id: nodeContent width: parent.width height: childrenRect.height color: "transparent" // Data Layout Column { id: body width: parent.width // Header Rectangle { id: header width: parent.width height: headerLayout.height color: root.baseColor radius: background.radius // Fill header's bottom radius Rectangle { width: parent.width height: parent.radius anchors.bottom: parent.bottom color: parent.color z: -1 } // Header Layout RowLayout { id: headerLayout width: parent.width spacing: 0 // Node Name Label { id: nodeLabel Layout.fillWidth: true text: root.nodeLabel padding: 4 color: root.mainSelected ? activePalette.highlightedText : activePalette.text elide: Text.ElideMiddle font.pointSize: 8 } // Node State icons RowLayout { Layout.fillWidth: true Layout.alignment: Qt.AlignRight Layout.rightMargin: 2 spacing: 2 // CompatibilityBadge icon for CompatibilityNodes Loader { active: root.isCompatibilityNode sourceComponent: CompatibilityBadge { sourceComponent: iconDelegate canUpgrade: root.node.canUpgrade issueDetails: root.node.issueDetails } } // Data sharing indicator // Note: for an unknown reason, there are some performance issues with the UI refresh. // Example: a node duplicated 40 times will be slow while creating another identical node // (sharing the same uid) will not be as slow. If save, quit and reload, it will become slow. MaterialToolButton { property string baseText: "Shares internal folder (data) with other node(s). Hold click for details." property string toolTipText: visible ? baseText : "" visible: node.hasDuplicates text: MaterialIcons.layers font.pointSize: 7 padding: 2 palette.text: Colors.sysPalette.text ToolTip.text: toolTipText onPressed: { offsetReleased.running = false toolTipText = visible ? generateDuplicateList() : "" } onReleased: { toolTipText = "" offsetReleased.running = true } onCanceled: released() // Used for a better user experience with the button // Avoid to change the text too quickly Timer { id: offsetReleased interval: 750 running: false repeat: false onTriggered: parent.toolTipText = visible ? parent.baseText : "" } } // Submitted externally indicator MaterialLabel { visible: node.isExternal text: MaterialIcons.cloud padding: 2 font.pointSize: 7 palette.text: Colors.sysPalette.text ToolTip.text: "Computed Externally" } // Lock indicator MaterialLabel { visible: root.readOnly text: MaterialIcons.lock padding: 2 font.pointSize: 7 palette.text: "red" ToolTip.text: "Locked" } MaterialLabel { id: nodeComment visible: node.comment !== "" || node.invalidation !== "" text: MaterialIcons.comment padding: 2 font.pointSize: 7 ToolTip { id: nodeCommentTooltip parent: header visible: nodeCommentMA.containsMouse && nodeComment.visible text: formatInternalAttributesTooltip(node.invalidation, node.comment) implicitWidth: 400 // Forces word-wrap for long comments but the tooltip will be bigger than needed for short comments delay: 300 // Relative position for the tooltip to ensure we will not get stuck in a case where it starts appearing over the mouse's // position because it is a bit long and cutting off the hovering of the mouse area (which leads to the tooltip beginning // to appear and immediately disappearing, over and over again) x: implicitWidth / 2.5 } MouseArea { // If the node header is hovered, comments may be displayed id: nodeCommentMA anchors.fill: parent hoverEnabled: true } } MaterialLabel { id: nodeImageOutput visible: (node.hasImageOutput || node.has3DOutput || node.hasSequenceOutput || node.hasTextOutput) text: MaterialIcons.visibility padding: 2 font.pointSize: 7 property bool displayable: !node.isComputableType || (node.chunks.count > 0 && (["SUCCESS"].includes(node.globalStatus))) color: displayable ? palette.text : Qt.darker(palette.text, 1.8) ToolTip { id: nodeImageOutputTooltip parent: header visible: nodeImageOutputMA.containsMouse && nodeImageOutput.visible text: { if ((node.hasImageOutput || node.hasSequenceOutput) && !node.has3DOutput) return nodeImageOutput.displayable ? "Double-click on this node to load its outputs in the Image Viewer." : "This node has image outputs." else if (node.has3DOutput && !node.hasImageOutput && !node.hasSequenceOutput) return nodeImageOutput.displayable ? "Double-click on this node to load its outputs in the 3D Viewer." : "This node has 3D outputs." else if (node.hasTextOutput && !node.hasImageOutput && !node.hasSequenceOutput && !node.has3DOutput) return nodeImageOutput.displayable ? "Double-click on this node to load its outputs in the Text Viewer." : "This node has text outputs." else // Handle case where a node might have both 2D and 3D outputs return nodeImageOutput.displayable ? "Double-click on this node to load its outputs in the Image or 3D Viewer." : "This node has image and 3D outputs." } implicitWidth: 500 delay: 300 // Relative position for the tooltip to ensure we will not get stuck in a case where it starts appearing over the mouse's // position because it is a bit long and cutting off the hovering of the mouse area (which leads to the tooltip beginning // to appear and immediately disappearing, over and over again) x: implicitWidth / 2.5 } MouseArea { // If the node header is hovered, comments may be displayed id: nodeImageOutputMA anchors.fill: parent hoverEnabled: true } } } } } // Node Chunks NodeChunks { visible: node.isComputableType targetNode: node defaultColor: Colors.sysPalette.mid implicitHeight: 3 width: parent.width model: { if (node && node.chunksCreated) return node.chunks else if (node && !node.chunksCreated) return node.chunkPlaceholder return undefined } Rectangle { anchors.fill: parent color: Colors.sysPalette.mid z: -1 } } // Vertical Spacer Item { width: parent.width; height: 2 } // Input/Output Attributes Item { id: nodeAttributes width: parent.width - 2 height: childrenRect.height anchors.horizontalCenter: parent.horizontalCenter Column { id: attributesColumn width: parent.width spacing: 5 bottomPadding: 2 Column { id: outputs width: parent.width spacing: 3 property var parentPins: new Map() signal parentPinsUpdated() Repeater { model: root.generateAttributesModel(true, outputs.parentPins) // isOutput = true delegate: Loader { id: outputLoader active: Boolean(modelData.isOutput && modelData.desc.visible) visible: { if (Boolean(modelData.enabled || modelData.hasAnyOutputLinks || modelData.hasAnyInputLinks)) { if (modelData.root && modelData.root.baseType === "GroupAttribute") { return Boolean(outputs.parentPins.get(modelData.root.name) || modelData.hasAnyOutputLinks || modelData.hasAnyInputLinks) } return true } return false } anchors.right: parent.right width: outputs.width Connections { target: outputs function onParentPinsUpdated() { visible = updateChildPin(modelData, outputs.parentPins, outputLoader.item) } } sourceComponent: AttributePin { id: outPin nodeItem: root attribute: modelData property real globalX: root.x + nodeAttributes.x + outputs.x + outputLoader.x + outPin.x property real globalY: root.y + nodeAttributes.y + outputs.y + outputLoader.y + outPin.y onIsConnectedChanged: function() { outputs.parentPinsUpdated() } onPressed: function(mouse) { root.pressed(mouse) } onClicked: function() { expanded = !expanded if (outputs.parentPins.has(modelData.name)) { outputs.parentPins.set(modelData.name, expanded) outputs.parentPinsUpdated() } } onEdgeAboutToBeRemoved: function(input) { root.edgeAboutToBeRemoved(input) } Component.onCompleted: attributePinCreated(attribute, outPin) onChildPinCreated: attributePinCreated(childAttribute, outPin) Component.onDestruction: attributePinDeleted(attribute, outPin) onChildPinDeleted: attributePinDeleted(childAttribute, outPin) } } } } Column { id: inputs width: parent.width spacing: 3 property var parentPins: new Map() signal parentPinsUpdated() Repeater { model: root.generateAttributesModel(false, inputs.parentPins) // isOutput = false delegate: Loader { id: inputLoader active: !modelData.isOutput && modelData.exposed && modelData.desc.visible visible: { if (Boolean(modelData.enabled)) { if (modelData.root && modelData.root.baseType === "GroupAttribute") { return Boolean(inputs.parentPins.get(modelData.root.name) || modelData.hasAnyOutputLinks || modelData.hasAnyInputLinks) } return true } return false } width: inputs.width Connections { target: inputs function onParentPinsUpdated() { visible = updateChildPin(modelData, inputs.parentPins, inputLoader.item) } } sourceComponent: AttributePin { id: inPin nodeItem: root attribute: modelData property real globalX: root.x + nodeAttributes.x + inputs.x + inputLoader.x + inPin.x property real globalY: root.y + nodeAttributes.y + inputs.y + inputLoader.y + inPin.y onIsConnectedChanged: function() { inputs.parentPinsUpdated() } readOnly: Boolean(root.readOnly || modelData.isReadOnly) Component.onCompleted: attributePinCreated(attribute, inPin) Component.onDestruction: attributePinDeleted(attribute, inPin) onPressed: function(mouse) { root.pressed(mouse) } onClicked: function() { expanded = !expanded if (inputs.parentPins.has(modelData.name)) { inputs.parentPins.set(modelData.name, expanded) inputs.parentPinsUpdated() } } onEdgeAboutToBeRemoved: function(input) { root.edgeAboutToBeRemoved(input) } onChildPinCreated: function(childAttribute, inPin) { attributePinCreated(childAttribute, inPin) } onChildPinDeleted: function(childAttribute, inPin) { attributePinDeleted(childAttribute, inPin) } } } } } // Vertical Spacer Rectangle { height: inputParams.height > 0 ? 3 : 0 visible: (height == 3) Behavior on height { PropertyAnimation {easing.type: Easing.Linear} } width: parent.width color: Colors.sysPalette.mid MaterialToolButton { text: " " width: parent.width height: parent.height padding: 0 spacing: 0 anchors.margins: 0 font.pointSize: 6 onClicked: { m.displayParams = ! m.displayParams } } } Rectangle { id: inputParamsRect width: parent.width height: childrenRect.height color: "transparent" Column { id: inputParams width: parent.width spacing: 3 property var parentPins: new Map() signal parentPinsUpdated() Repeater { model: root.generateAttributesModel(false, inputParams.parentPins) // isOutput = false delegate: Loader { id: paramLoader active: !modelData.isOutput && !modelData.exposed && modelData.desc.visible visible: { if (Boolean(modelData.enabled || modelData.hasAnyOutputLinks || modelData.hasAnyInputLinks)) { if (modelData.root && modelData.root.baseType === "GroupAttribute") { return Boolean(inputParams.parentPins.get(modelData.root.name) || modelData.hasAnyOutputLinks || modelData.hasAnyInputLinks) } return true } return false } property bool isFullyActive: Boolean(m.displayParams || modelData.hasAnyInputLinks || modelData.hasAnyOutputLinks) width: parent.width Connections { target: inputParams function onParentPinsUpdated() { visible = updateChildPin(modelData, inputParams.parentPins, paramLoader.item) } } sourceComponent: AttributePin { id: inParamsPin nodeItem: root attribute: modelData property real globalX: root.x + nodeAttributes.x + inputParamsRect.x + paramLoader.x + inParamsPin.x property real globalY: root.y + nodeAttributes.y + inputParamsRect.y + paramLoader.y + inParamsPin.y onIsConnectedChanged: function() { inputParams.parentPinsUpdated() } height: isFullyActive ? childrenRect.height : 0 Behavior on height { PropertyAnimation {easing.type: Easing.Linear} } visible: (height == childrenRect.height) readOnly: Boolean(root.readOnly || modelData.isReadOnly) Component.onCompleted: attributePinCreated(attribute, inParamsPin) Component.onDestruction: attributePinDeleted(attribute, inParamsPin) onPressed: function(mouse) { root.pressed(mouse) } onClicked: function() { expanded = !expanded if (inputParams.parentPins.has(modelData.name)) { inputParams.parentPins.set(modelData.name, expanded) inputParams.parentPinsUpdated() } } onEdgeAboutToBeRemoved: function(input) { root.edgeAboutToBeRemoved(input) } onChildPinCreated: function(childAttribute, inParamsPin) { attributePinCreated(childAttribute, inParamsPin) } onChildPinDeleted: function(childAttribute, inParamsPin) { attributePinDeleted(childAttribute, inParamsPin) } } } } } } MaterialToolButton { text: root.hovered ? (m.displayParams ? MaterialIcons.arrow_drop_up : MaterialIcons.arrow_drop_down) : " " Layout.alignment: Qt.AlignBottom width: parent.width height: 5 padding: 0 spacing: 0 anchors.margins: 0 font.pointSize: 10 onClicked: { m.displayParams = ! m.displayParams } } } } } } } } ================================================ FILE: meshroom/ui/qml/GraphEditor/NodeChunks.qml ================================================ import QtQuick import Utils 1.0 ListView { id: root interactive: false property bool highlightChunks: true SystemPalette { id: activePalette } property var targetNode: null property color defaultColor: Qt.darker(activePalette.window, 1.1) property real chunkHeight: height property int modelSize: model ? model.count : 0 property bool modelIsBig: (3 * modelSize >= width) property real chunkWidth: { if (modelSize == 0) return 0 return (width / modelSize) - spacing } orientation: ListView.Horizontal // If we have enough space, add one pixel margin between chunks spacing: modelIsBig ? 0 : 1 delegate: Rectangle { id: chunkDelegate height: root.chunkHeight width: root.chunkWidth property var chunkColor: Colors.getChunkColor(object, { "NONE": root.defaultColor }) color: { if (!highlightChunks || modelSize == 1) return chunkColor if (index % 2 == 0) return Qt.lighter(chunkColor, 1.1) else return Qt.darker(chunkColor, 1.1) } } } ================================================ FILE: meshroom/ui/qml/GraphEditor/NodeDocumentation.qml ================================================ import QtQuick import QtQuick.Controls import QtQuick.Layouts import Controls 1.0 /** * Displays Node documentation */ FocusScope { id: root property variant node SystemPalette { id: activePalette } ScrollView { width: parent.width height: parent.height ScrollBar.vertical.policy: ScrollBar.AlwaysOn ScrollBar.horizontal.policy: ScrollBar.AlwaysOff clip: true ColumnLayout { id: nodeDocColumnLayout property real keyColumnWidth: 10.0 * Qt.application.font.pixelSize Component { id: nodeInfoItem Rectangle { color: activePalette.window width: parent.width height: childrenRect.height RowLayout { width: parent.width Rectangle { id: nodeInfoKey anchors.margins: 2 color: Qt.darker(activePalette.window, 1.1) Layout.preferredWidth: nodeDocColumnLayout.keyColumnWidth Layout.minimumWidth: 0.2 * parent.width Layout.maximumWidth: 0.8 * parent.width Layout.fillWidth: false Layout.fillHeight: true Label { text: modelData.key font.capitalization: Font.Capitalize anchors.fill: parent anchors.top: parent.top topPadding: 4 leftPadding: 6 verticalAlignment: TextEdit.AlignTop elide: Text.ElideRight } } // Drag handle for resizing Rectangle { width: 2 Layout.fillHeight: true color: "transparent" MouseArea { anchors.fill: parent anchors.margins: -2 cursorShape: Qt.SizeHorCursor drag { target: parent axis: Drag.XAxis threshold: 0 // Not required minimumX: 0.2 * nodeDocColumnLayout.width maximumX: 0.8 * nodeDocColumnLayout.width } onPositionChanged: (mouse)=> { nodeDocColumnLayout.keyColumnWidth = parent.x } } } TextArea { id: nodeInfoValue text: modelData.value anchors.margins: 2 Layout.fillWidth: true Layout.fillHeight: true wrapMode: Label.WrapAtWordBoundaryOrAnywhere textFormat: TextEdit.PlainText readOnly: true selectByMouse: true background: Rectangle { anchors.fill: parent; color: Qt.darker(activePalette.window, 1.05) } } } } } ListView { id: nodeInfoListView width: parent.width height: childrenRect.height Layout.preferredWidth: width spacing: 3 model: node.nodeInfo delegate: nodeInfoItem } TextEdit { id: documentationText padding: 8 topPadding: 20 Layout.alignment: Qt.AlignTop | Qt.AlignLeft Layout.preferredWidth: width width: parent.parent.parent.width textFormat: TextEdit.MarkdownText selectByMouse: true selectionColor: activePalette.highlight color: activePalette.text text: node ? node.documentation : "" wrapMode: TextEdit.Wrap } } } } ================================================ FILE: meshroom/ui/qml/GraphEditor/NodeEditor.qml ================================================ import QtQuick import QtQuick.Controls import QtQuick.Layouts import Controls 1.0 import MaterialIcons 2.2 import Utils 1.0 import Shapes 1.0 /** * NodeEditor allows to visualize and edit the parameters of a Node. * It mainly provides an attribute editor and a log inspector. */ Panel { id: root property variant node property string globalStatus : node !== null ? node.globalStatus : "" property bool readOnly: false property bool isCompatibilityNode: node && node.compatibilityIssue !== undefined property string nodeStartDateTime: "" property variant nodeName: node !== null ? node.name : undefined property string displayNodeName: node !== null ? node.name : "" property string validatedNodeName: displayNodeName property string displayNodeType: "" function updateNodeNameDisplay() { if (_currentScene.selectedNode) { const nodeName = _currentScene.selectedNode.name root.displayNodeName = nodeName root.validatedNodeName = nodeName // Set the display node type only if it is not contained in the node name const nodeType = _currentScene.selectedNode.nodeType root.displayNodeType = nodeName.startsWith(nodeType + "_") ? "" : nodeType } } Connections { target: _currentScene function onSelectedNodeChanged() { updateNodeNameDisplay() } } onNodeNameChanged: { updateNodeNameDisplay() } signal attributeDoubleClicked(var mouse, var attribute) signal inAttributeClicked(var srcItem, var mouse, var inAttributes) signal outAttributeClicked(var srcItem, var mouse, var outAttributes) signal showAttributeInViewer(var attribute) signal upgradeRequest() title: "Node" + (node !== null ? " - " + node.label + "" + (node.label !== node.defaultLabel ? " (" + node.defaultLabel + ")" : "") : "") icon: MaterialLabel { text: MaterialIcons.tune } onGlobalStatusChanged: { nodeStartDateTime = "" if (node !== null && node.isRunning()) { timer.start() } else { timer.stop() if (node !== null && (node.isFinishedOrRunning() || globalStatus == "ERROR")) { computationInfo.text = Format.sec2timeStr(node.elapsedTime) } else { computationInfo.text = "" } } } function refresh() { /** * Refresh properties of the Node Editor. */ // Reset tab bar's current index tabBar.currentIndex = 0; } // Function to validate and apply node name change function validateNodeNameChange(name) { if (root.node && name.trim() !== "") { const newNodeName = _currentScene.renameNode(_currentScene.selectedNode, name.trim()) if (newNodeName === "") { root.displayNodeName = root.nodeName root.validatedNodeName = root.nodeName } else { root.displayNodeName = newNodeName root.validatedNodeName = newNodeName } } } function cancelNodeNameChange() { // HACK: Set to an empty string to force the text to be set to the previous value. root.displayNodeName = "" root.displayNodeName = root.validatedNodeName } // Add custom title component for editing titleComponent: Component { RowLayout { spacing: 4 Label { text: root.node === null ? "NodeEditor" : "Node -" topPadding: 4 bottomPadding: 4 rightPadding: 0 } TextField { id: nodeNameField visible: root.node !== null text: root.displayNodeName // For some reason the validator does not always work validator: RegularExpressionValidator { regularExpression: /^[0-9A-Za-z]+$/ } font.bold: true readOnly: true selectByMouse: false verticalAlignment: Text.AlignVCenter topPadding: 4 bottomPadding: 4 leftPadding: 0 background: Rectangle { color: nodeNameField.readOnly ? "transparent" : root.palette.base border.color: nodeNameField.readOnly ? "transparent" : root.palette.highlight border.width: 1 radius: 2 } function refreshText() { nodeNameField.text = Qt.binding(function() { return root.displayNodeName }) } MouseArea { anchors.fill: parent enabled: nodeNameField.readOnly onDoubleClicked: { if (root.node && !root.node.locked) { nodeNameField.readOnly = false nodeNameField.selectByMouse = true nodeNameField.forceActiveFocus() nodeNameField.selectAll() } } } Keys.onReturnPressed: { if (!readOnly) { root.validateNodeNameChange(text) nodeNameField.refreshText() readOnly = true selectByMouse = false } } Keys.onEnterPressed: { if (!readOnly) { root.validateNodeNameChange(text) nodeNameField.refreshText() readOnly = true selectByMouse = false } } Keys.onEscapePressed: { if (!readOnly) { root.cancelNodeNameChange() nodeNameField.refreshText() readOnly = true selectByMouse = false } } onActiveFocusChanged: { if (!activeFocus && !readOnly) { // Focus lost without pressing Enter - discard changes root.cancelNodeNameChange() nodeNameField.refreshText() readOnly = true selectByMouse = false } } Connections { target: _currentScene function onSelectedNodeChanged() { if (!activeFocus && !readOnly) { root.cancelNodeNameChange() nodeNameField.refreshText() nodeNameField.readOnly = true nodeNameField.selectByMouse = false } } } } // Show node type if the node name does not start with "nodeType_" Label { text: "(" + root.displayNodeType + ")" visible: root.displayNodeType !== "" && _currentScene.selectedNode topPadding: 4 bottomPadding: 4 } } } headerBar: RowLayout { Label { id: computationInfo color: node && node.isComputableType ? Colors.statusColors[node.globalStatus] : palette.text Timer { id: timer interval: 2500 triggeredOnStart: true repeat: true running: node !== null && node.isRunning() onTriggered: { if (nodeStartDateTime === "") { nodeStartDateTime = new Date(node.getStartDateTime()).getTime() } var now = new Date().getTime() parent.text = Format.sec2timeStr((now-nodeStartDateTime)/1000) } } padding: 2 font.italic: true visible: { if (node !== null) { if (node.isComputableType && (node.isFinishedOrRunning() || node.isSubmittedOrRunning() || node.globalStatus=="ERROR")) { return true } } return false } ToolTip.text: { if (node !== null && (node.isFinishedOrRunning() || (node.isSubmittedOrRunning() && node.elapsedTime > 0))) { var longestChunkTime = getLongestChunkTime(node.chunks) if (longestChunkTime > 0) return "Longest chunk: " + Format.sec2timeStr(longestChunkTime) + " (" + node.chunks.count + " chunks)" else return "" } else { return "" } } ToolTip.visible: ToolTip.text ? runningTimeMa.containsMouse : false MouseArea { id: runningTimeMa anchors.fill: parent hoverEnabled: true } function getLongestChunkTime(chunks) { if (chunks.count <= 1) return 0 var longestChunkTime = 0 for (var i = 0; i < chunks.count; i++) { var elapsedTime = chunks.at(i).elapsedTime longestChunkTime = elapsedTime > longestChunkTime ? elapsedTime : longestChunkTime } return longestChunkTime } } SearchBar { id: searchBar toggle: true // Enable toggling the actual text field by the search button Layout.minimumWidth: searchBar.width maxWidth: 150 enabled: tabBar.currentIndex === 0 || tabBar.currentIndex === 6 } MaterialToolButton { text: MaterialIcons.more_vert font.pointSize: 11 padding: 2 onClicked: settingsMenu.open() checkable: true checked: settingsMenu.visible Menu { id: settingsMenu y: parent.height Menu { id: filterAttributesMenu title: "Filter Attributes" RowLayout { CheckBox { id: outputToggle text: "Output" checkable: true checked: GraphEditorSettings.showOutputAttributes onClicked: GraphEditorSettings.showOutputAttributes = !GraphEditorSettings.showOutputAttributes enabled: tabBar.currentIndex === 0 } CheckBox { id: inputToggle text: "Input" checkable: true checked: GraphEditorSettings.showInputAttributes onClicked: GraphEditorSettings.showInputAttributes = !GraphEditorSettings.showInputAttributes enabled: tabBar.currentIndex === 0 } } MenuSeparator {} RowLayout { CheckBox { id: defaultToggle text: "Default" checkable: true checked: GraphEditorSettings.showDefaultAttributes onClicked: GraphEditorSettings.showDefaultAttributes = !GraphEditorSettings.showDefaultAttributes enabled: tabBar.currentIndex === 0 } CheckBox { id: modifiedToggle text: "Modified" checkable: true checked: GraphEditorSettings.showModifiedAttributes onClicked: GraphEditorSettings.showModifiedAttributes = !GraphEditorSettings.showModifiedAttributes enabled: tabBar.currentIndex === 0 } } MenuSeparator {} RowLayout { CheckBox { id: linkToggle text: "Link" checkable: true checked: GraphEditorSettings.showLinkAttributes onClicked: GraphEditorSettings.showLinkAttributes = !GraphEditorSettings.showLinkAttributes enabled: tabBar.currentIndex === 0 } CheckBox { id: notLinkToggle text: "Not Link" checkable: true checked: GraphEditorSettings.showNotLinkAttributes onClicked: GraphEditorSettings.showNotLinkAttributes = !GraphEditorSettings.showNotLinkAttributes enabled: tabBar.currentIndex === 0 } } MenuSeparator {} CheckBox { id: advancedToggle text: "Advanced" MaterialLabel { anchors.right: parent.right; anchors.rightMargin: parent.padding; text: MaterialIcons.build anchors.verticalCenter: parent.verticalCenter font.pointSize: 8 } checkable: true checked: GraphEditorSettings.showAdvancedAttributes onClicked: GraphEditorSettings.showAdvancedAttributes = !GraphEditorSettings.showAdvancedAttributes } } MenuItem { text: "Open Cache Folder" enabled: root.node !== null onClicked: Qt.openUrlExternally(Filepath.stringToUrl(root.node.internalFolder)) } MenuSeparator {} MenuItem { enabled: root.node !== null text: "Clear Pending Status" onClicked: { node.clearSubmittedChunks() timer.stop() } } } } } ColumnLayout { anchors.fill: parent // CompatibilityBadge banner for CompatibilityNode Loader { active: root.isCompatibilityNode Layout.fillWidth: true visible: active // For layout update sourceComponent: CompatibilityBadge { canUpgrade: root.node.canUpgrade issueDetails: root.node.issueDetails onUpgradeRequest: root.upgradeRequest() sourceComponent: bannerDelegate } } Loader { Layout.fillHeight: true Layout.fillWidth: true sourceComponent: root.node ? editor_component : placeholder_component Component { id: placeholder_component Item { Column { anchors.centerIn: parent MaterialLabel { text: MaterialIcons.select_all font.pointSize: 34 color: Qt.lighter(palette.mid, 1.2) anchors.horizontalCenter: parent.horizontalCenter } Label { color: Qt.lighter(palette.mid, 1.2) text: "Select a Node to access its Details" } } } } Component { id: editor_component MSplitView { anchors.fill: parent // The list of chunks ChunksListView { id: chunksLV enabled: root.node ? root.node.chunksCreated : false chunks: root.node ? root.node.chunks : null visible: enabled && (tabBar.currentIndex >= 1 && tabBar.currentIndex <= 3) SplitView.preferredWidth: 55 SplitView.minimumWidth: 20 } StackLayout { SplitView.fillWidth: true currentIndex: tabBar.currentIndex // First tab MSplitView { orientation: Qt.Vertical // Node shape editor Loader { id: shapeEditorLoader active: _currentScene ? (_currentScene.selectedNode ? _currentScene.selectedNode.hasDisplayableShape : false) : false sourceComponent: ShapeEditor { model: root.node.attributes filterText: searchBar.text } SplitView.preferredHeight: active ? 200 : 0 SplitView.minimumHeight: active ? 100 : 0 SplitView.maximumHeight: active ? 400 : 0 } // Node attribute editor AttributeEditor { id: inOutAttr objectsHideable: true Layout.fillHeight: true Layout.fillWidth: true SplitView.minimumHeight: 100 model: root.node.attributes readOnly: root.readOnly || root.isCompatibilityNode onAttributeDoubleClicked: function(mouse, attribute) { root.attributeDoubleClicked(mouse, attribute) } onUpgradeRequest: root.upgradeRequest() onShowInViewer: function (attribute) {root.showAttributeInViewer(attribute)} filterText: searchBar.text onInAttributeClicked: function(srcItem, mouse, inAttributes) { root.inAttributeClicked(srcItem, mouse, inAttributes) } onOutAttributeClicked: function(srcItem, mouse, outAttributes) { root.outAttributeClicked(srcItem, mouse, outAttributes) } } } Loader { active: (tabBar.currentIndex === 1) Layout.fillHeight: true Layout.fillWidth: true sourceComponent: NodeLog { // anchors.fill: parent Layout.fillHeight: true Layout.fillWidth: true width: parent.width height: parent.height id: nodeLog node: root.node currentChunkIndex: chunksLV.currentIndex currentChunk: chunksLV.currentChunk } } Loader { active: (tabBar.currentIndex === 2) Layout.fillHeight: true Layout.fillWidth: true sourceComponent: NodeStatistics { id: nodeStatistics Layout.fillHeight: true Layout.fillWidth: true node: root.node currentChunkIndex: chunksLV.currentIndex currentChunk: chunksLV.currentChunk } } Loader { active: (tabBar.currentIndex === 3) Layout.fillHeight: true Layout.fillWidth: true sourceComponent: NodeStatus { id: nodeStatus Layout.fillHeight: true Layout.fillWidth: true node: root.node currentChunkIndex: chunksLV.currentIndex currentChunk: chunksLV.currentChunk } } Loader { active: (tabBar.currentIndex === 4) Layout.fillHeight: true Layout.fillWidth: true sourceComponent: NodeFileBrowser { id: nodeFileBrowser Layout.fillHeight: true Layout.fillWidth: true node: root.node } } NodeDocumentation { id: nodeDocumentation Layout.fillHeight: true Layout.fillWidth: true node: root.node } AttributeEditor { id: nodeInternalAttr objectsHideable: false Layout.fillHeight: true Layout.fillWidth: true model: root.node.internalAttributes readOnly: root.readOnly || root.isCompatibilityNode onAttributeDoubleClicked: function(mouse, attribute) { root.attributeDoubleClicked(mouse, attribute) } onUpgradeRequest: root.upgradeRequest() filterText: searchBar.text onInAttributeClicked: function(srcItem, mouse, inAttributes) { root.inAttributeClicked(srcItem, mouse, inAttributes) } onOutAttributeClicked: function(srcItem, mouse, outAttributes) { root.outAttributeClicked(srcItem, mouse, outAttributes) } } } } } } TabBar { id: tabBar visible: root.node !== null property bool isComputableType: root.node !== null && root.node.isComputableType property bool isBackdropNode: root.node !== null && root.node.isBackdropNode // The indices of the tab bar which can be shown for incomputable nodes readonly property var nonComputableTabIndices: [0, 5, 6] Layout.fillWidth: true width: childrenRect.width position: TabBar.Footer currentIndex: 0 TabButton { text: "Attributes" visible: !tabBar.isBackdropNode width: { if (!visible) return 0 else { if (tabBar.isComputableType) return tabBar.width / tabBar.count else { return tabBar.width / tabBar.nonComputableTabIndices.length } } } padding: 4 leftPadding: 8 rightPadding: leftPadding } TabButton { visible: tabBar.isComputableType width: !visible ? 0 : tabBar.width / tabBar.count text: "Log" leftPadding: 8 rightPadding: leftPadding } TabButton { visible: tabBar.isComputableType width: !visible ? 0 : tabBar.width / tabBar.count text: "Statistics" leftPadding: 8 rightPadding: leftPadding } TabButton { visible: tabBar.isComputableType width: !visible ? 0 : tabBar.width / tabBar.count text: "Status" leftPadding: 8 rightPadding: leftPadding } TabButton { visible: tabBar.isComputableType width: !visible ? 0 : tabBar.width / tabBar.count text: "Files" leftPadding: 8 rightPadding: leftPadding } TabButton { text: "Documentation" leftPadding: 8 rightPadding: leftPadding } TabButton { text: "Notes" padding: 4 leftPadding: 8 rightPadding: leftPadding } onVisibleChanged: { // If we have a node selected and the node is not Computable // Reset the currentIndex to 0, if the current index is not allowed for an incomputable node if ((root.node && !root.node.isComputableType) && (nonComputableTabIndices.indexOf(tabBar.currentIndex) === -1)) { if (root.node.isBackdropNode) { // Backdrop nodes can only show the Documentation & Notes tabs tabBar.currentIndex = 5 // Documentation tab } else { tabBar.currentIndex = 0 } } } } } } ================================================ FILE: meshroom/ui/qml/GraphEditor/NodeFileBrowser.qml ================================================ import QtQuick import QtQuick.Controls import QtQuick.Layouts import Qt.labs.folderlistmodel import Controls 1.0 import MaterialIcons 2.2 import Utils 1.0 /** * NodeFileBrowser displays the cache folder of a Node as a navigable file browser. */ FocusScope { id: root property variant node: null // The root folder URL (node's internal cache folder) readonly property url rootFolderUrl: node ? Filepath.stringToUrl(node.internalFolder) : "" // Currently displayed folder URL property url currentFolder: rootFolderUrl // Reset to root folder when node changes onRootFolderUrlChanged: { root.currentFolder = root.rootFolderUrl } // Height of a normal (non-hidden) delegate item readonly property int itemHeight: 24 readonly property bool isValidFolder: Filepath.exists(root.currentFolder) /** * Returns true if the given file name is a Meshroom-internal file that should be hidden, * i.e. nodeStatus, chunk log/statistics/status files (e.g. 0.log, 0.statistics, 0.status). */ function isInternalFile(name) { return name === "nodeStatus" || name.endsWith(".log") || name.endsWith(".statistics") || name.endsWith(".status") } SystemPalette { id: activePalette } FolderListModel { id: folderModel folder: root.currentFolder showFiles: true showDirs: true showDirsFirst: true showHidden: false sortField: FolderListModel.Name nameFilters: ["*"] } ColumnLayout { anchors.fill: parent spacing: 0 // Toolbar: navigate up button, current path label, open-in-OS button ToolBar { Layout.fillWidth: true RowLayout { anchors.fill: parent spacing: 2 // Navigate up button MaterialToolButton { text: MaterialIcons.arrow_upward font.pointSize: 11 padding: 4 enabled: root.currentFolder.toString() !== root.rootFolderUrl.toString() ToolTip.text: "Go to parent folder" ToolTip.visible: hovered onClicked: { root.currentFolder = Filepath.stringToUrl(Filepath.dirname(Filepath.urlToString(root.currentFolder))) } } // Current folder path label Label { id: pathLabel Layout.fillWidth: true elide: Text.ElideLeft text: root.node ? Filepath.urlToString(root.currentFolder) : "" ToolTip.text: text ToolTip.visible: hovered && truncated font.pointSize: 8 verticalAlignment: Text.AlignVCenter } // Open current folder in OS file manager MaterialToolButton { text: MaterialIcons.folder_open font.pointSize: 11 padding: 4 enabled: root.node !== null ToolTip.text: "Open folder in file manager" ToolTip.visible: hovered onClicked: Qt.openUrlExternally(root.currentFolder) } } } // File list ListView { id: fileListView Layout.fillWidth: true Layout.fillHeight: true clip: true focus: true // When the folder does not exist, the FolderModel has a fallback to a default folder. // We disable the model to avoid this problematic behavior. model: isValidFolder ? folderModel : null keyNavigationEnabled: true highlightFollowsCurrentItem: true ScrollBar.vertical: ScrollBar { policy: ScrollBar.AsNeeded } // Placeholder when folder is empty, does not exist, or contains only internal files Label { anchors.centerIn: parent visible: root.node !== null && fileListView.contentHeight === 0 color: Qt.lighter(activePalette.mid, 1.2) text: isValidFolder ? "Empty folder" : "Folder does not exist" } delegate: ItemDelegate { id: delegateItem width: fileListView.width // Hide Meshroom-internal files by collapsing their height height: root.isInternalFile(fileName) ? 0 : root.itemHeight visible: height > 0 padding: 0 leftPadding: 6 // fileIsDir is a FolderListModel role available in the delegate context readonly property bool isDir: fileIsDir readonly property string itemFilePath: filePath RowLayout { anchors.fill: parent anchors.leftMargin: 6 spacing: 6 // File/folder icon MaterialLabel { text: delegateItem.isDir ? MaterialIcons.folder : MaterialIcons.insert_drive_file color: delegateItem.isDir ? "#e8a000" : activePalette.text font.pointSize: 10 Layout.alignment: Qt.AlignVCenter } // File/folder name Label { Layout.fillWidth: true // fileName is a FolderListModel role available in the delegate context text: fileName elide: Text.ElideRight font.pointSize: 8 verticalAlignment: Text.AlignVCenter } // File size (only for files, fileSize role from FolderListModel) Label { visible: !delegateItem.isDir text: { if (fileSize < 0) return "" if (fileSize < 1024) return fileSize + " B" if (fileSize < 1024 * 1024) return (fileSize / 1024).toFixed(1) + " KB" if (fileSize < 1024 * 1024 * 1024) return (fileSize / (1024 * 1024)).toFixed(1) + " MB" return (fileSize / (1024 * 1024 * 1024)).toFixed(2) + " GB" } color: activePalette.mid font.pointSize: 7 rightPadding: 8 verticalAlignment: Text.AlignVCenter } } highlighted: fileListView.currentIndex === index onDoubleClicked: { if (delegateItem.isDir) { // fileURL is a FolderListModel role providing the URL root.currentFolder = fileURL } else { Qt.openUrlExternally(fileURL) } } } } } } ================================================ FILE: meshroom/ui/qml/GraphEditor/NodeLog.qml ================================================ import QtQuick import QtQuick.Controls import QtQuick.Layouts import Controls 1.0 /** * NodeLog displays the log file of Node's chunks (NodeChunks). * * To ease monitoring, it provides periodic auto-reload of the opened file * if the related NodeChunk is being computed. */ FocusScope { id: root property variant node property int currentChunkIndex property variant currentChunk Layout.fillWidth: true Layout.fillHeight: true SystemPalette { id: activePalette } Loader { id: componentLoader clip: true anchors.fill: parent property string currentFile: (root.currentChunkIndex >= 0 && root.currentChunk) ? root.currentChunk["logFile"] : "" property url sourceFile: Filepath.stringToUrl(currentFile) sourceComponent: textFileViewerComponent } Component { id: textFileViewerComponent TextFileViewer { id: textFileViewer anchors.fill: parent source: componentLoader.sourceFile autoReload: root.currentChunk !== undefined && root.currentChunk.statusName === "RUNNING" } } } ================================================ FILE: meshroom/ui/qml/GraphEditor/NodeStatistics.qml ================================================ import QtQuick import QtQuick.Controls import QtQuick.Layouts import Controls 1.0 import Utils 1.0 /** * NodeStatistics displays statistics data of Node's chunks (NodeChunks). * * To ease monitoring, it provides periodic auto-reload of the opened file * if the related NodeChunk is being computed. */ FocusScope { id: root property variant node property variant currentChunkIndex property variant currentChunk SystemPalette { id: activePalette } Loader { id: componentLoader clip: true anchors.fill: parent property string currentFile: currentChunk ? currentChunk["statisticsFile"] : "" property url sourceFile: Filepath.stringToUrl(currentFile) sourceComponent: chunksLV.chunksSummary ? statViewerComponent : chunkStatViewerComponent } Component { id: chunkStatViewerComponent StatViewer { id: statViewer anchors.fill: parent source: componentLoader.sourceFile } } Component { id: statViewerComponent Column { spacing: 2 KeyValue { key: "Time" property real time: node.elapsedTime value: time > 0.0 ? Format.sec2timecode(time) : "-" } KeyValue { key: "Cumulated Time" property real time: node.recursiveElapsedTime value: time > 0.0 ? Format.sec2timecode(time) : "-" } } } } ================================================ FILE: meshroom/ui/qml/GraphEditor/NodeStatus.qml ================================================ import QtQuick import QtQuick.Controls import QtQuick.Layouts /** * NodeStatus displays the status-related information of Node's chunks (NodeChunks) * * To ease monitoring, it provides periodic auto-reload of the opened file * if the related NodeChunk is being computed. */ FocusScope { id: root property variant node property variant currentChunkIndex property variant currentChunk property bool isChunkValid: (root.currentChunkIndex >= 0 && root.currentChunk !== undefined) SystemPalette { id: activePalette } Loader { id: componentLoader clip: true anchors.fill: parent property string currentFile: root.isChunkValid ? root.currentChunk["statusFile"] : "" property url sourceFile: Filepath.stringToUrl(currentFile) sourceComponent: statViewerComponent } Component { id: statViewerComponent Item { id: statusViewer property url source: componentLoader.sourceFile property var lastModified: undefined property variant chunkStatus: root.isChunkValid ? root.currentChunk.status : undefined onChunkStatusChanged: { statusListModel.readSourceFile() } onSourceChanged: { statusListModel.readSourceFile() } ListModel { id: statusListModel function readSourceFile() { // Make sure we are trying to load a statistics file if (!Filepath.urlToString(source).endsWith("status")) return var xhr = new XMLHttpRequest xhr.open("GET", source) xhr.onreadystatechange = function() { if (xhr.readyState === XMLHttpRequest.DONE && xhr.status === 200) { if (lastModified === undefined || lastModified !== xhr.getResponseHeader('Last-Modified')) { lastModified = xhr.getResponseHeader('Last-Modified') try { var jsonObject = JSON.parse(xhr.responseText) var entries = [] // Prepare data to populate the ListModel from the input json object for (var key in jsonObject) { var entry = {} entry["key"] = key entry["value"] = String(jsonObject[key]) entries.push(entry) } // Reset the model with prepared data (limit to one update event) statusListModel.clear() statusListModel.append(entries) } catch(exc) { lastModified = undefined statusListModel.clear() } } } else { lastModified = undefined statusListModel.clear() } } xhr.send() } } ListView { id: statusListView anchors.fill: parent spacing: 3 model: statusListModel delegate: Rectangle { color: activePalette.window width: statusListView.width height: childrenRect.height RowLayout { width: parent.width Rectangle { id: statusKey anchors.margins: 2 color: Qt.darker(activePalette.window, 1.1) Layout.preferredWidth: sizeHandle.x Layout.minimumWidth: 10.0 * Qt.application.font.pixelSize Layout.maximumWidth: 15.0 * Qt.application.font.pixelSize Layout.fillWidth: false Layout.fillHeight: true Label { text: key anchors.fill: parent anchors.top: parent.top topPadding: 4 leftPadding: 6 verticalAlignment: TextEdit.AlignTop elide: Text.ElideRight } } TextArea { id: statusValue text: value anchors.margins: 2 Layout.fillWidth: true wrapMode: Label.WrapAtWordBoundaryOrAnywhere textFormat: TextEdit.PlainText readOnly: true selectByMouse: true background: Rectangle { anchors.fill: parent; color: Qt.darker(activePalette.window, 1.05) } } } } } // Categories resize handle Rectangle { id: sizeHandle height: parent.contentHeight width: 1 x: parent.width * 0.2 MouseArea { anchors.fill: parent anchors.margins: -4 cursorShape: Qt.SizeHorCursor drag { target: parent axis: Drag.XAxis threshold: 0 minimumX: statusListView.width * 0.2 maximumX: statusListView.width * 0.8 } } } } } } ================================================ FILE: meshroom/ui/qml/GraphEditor/ScriptEditor.qml ================================================ import QtQuick import QtQuick.Controls import QtQuick.Dialogs import QtQuick.Layouts import Controls 1.0 import MaterialIcons 2.2 import Utils 1.0 import Qt.labs.platform as Platform import ScriptEditor 1.0 Item { id: root // Defines the parent or the root Application of which this script editor is a part of property var rootApplication: undefined; Component { id: clearConfirmationDialog MessageDialog { title: "Clear history" preset: "Warning" text: "This will clear all history of executed scripts." helperText: "Are you sure you would like to continue?." standardButtons: Dialog.Ok | Dialog.Cancel onClosed: destroy() } } function replace(text, string, replacement) { /** * Replaces all occurrences of the string in the text * @param text - overall text * @param string - the string to be replaced in the text * @param replacement - the replacement of the string */ // Split with the string let lines = text.split(string) // Return the overall text joined with the replacement return lines.join(replacement) } function formatInput(text) { /** * Formats the text to be displayed as the input script executed */ // Replace the text to be RichText Supportive return "> Input:
" + replace(text, "\n", "
") + "

" } function formatOutput(text) { /** * Formats the text to be displayed as the result of the script executed */ // Replace the text to be RichText Supportive return "> Result:
" + replace(text, "\n", "
") + "

" } function clearHistory() { /** * Clears all of the executed history from the script editor */ ScriptEditorManager.clearHistory() input.clear() output.clear() } function processScript(text = "") { // Use either the provided/selected or the entire script text = text || input.text // Execute the process and fetch back the return for it var ret = ScriptEditorManager.process(text) // Append the input script and the output result to the output console output.append(formatInput(text) + formatOutput(ret)) // Save the entire script after executing the commands ScriptEditorManager.saveScript(input.text) } function loadScript(fileUrl) { var request = new XMLHttpRequest() request.open("GET", fileUrl, false) request.send(null) return request.responseText } function saveScript(fileUrl, content) { var request = new XMLHttpRequest() request.open("PUT", fileUrl, false) request.send(content) return request.status } implicitWidth: 500 implicitHeight: 500 Platform.FileDialog { id: loadScriptDialog title: "Load Script" nameFilters: ["Python Script (*.py)"] onAccepted: { input.clear() input.text = loadScript(currentFile) } } Platform.FileDialog { id: saveScriptDialog title: "Save script" nameFilters: ["Python Script (*.py)"] fileMode: Platform.FileDialog.SaveFile signal closed(var result) onAccepted: { if (Filepath.extension(currentFile) != ".py") currentFile = currentFile + ".py" var ret = saveScript(currentFile, input.text) if (ret) closed(Platform.Dialog.Accepted) else closed(Platform.Dialog.Rejected) } onRejected: closed(Platform.Dialog.Rejected) } ColumnLayout { anchors.fill: parent RowLayout { Layout.alignment: Qt.AlignVCenter | Qt.AlignHCenter MaterialToolButton { font.pointSize: 13 text: MaterialIcons.file_open ToolTip.text: "Load Script" onClicked: { loadScriptDialog.open() } } MaterialToolButton { font.pointSize: 13 text: MaterialIcons.save ToolTip.text: "Save Script" onClicked: { saveScriptDialog.open() } } MaterialToolButton { font.pointSize: 13 text: MaterialIcons.history ToolTip.text: "Get Previous Script" enabled: ScriptEditorManager.hasPreviousScript; onClicked: { var ret = ScriptEditorManager.getPreviousScript() if (ret != "") { input.clear() input.text = ret } } } MaterialToolButton { font.pointSize: 13 text: MaterialIcons.update ToolTip.text: "Get Next Script" enabled: ScriptEditorManager.hasNextScript; onClicked: { var ret = ScriptEditorManager.getNextScript() if (ret != "") { input.clear() input.text = ret } } } MaterialToolButton { font.pointSize: 13 text: MaterialIcons.delete_sweep ToolTip.text: "Clear History" onClicked: { // Confirm from the user before clearing out any history const confirmationDialog = clearConfirmationDialog.createObject(rootApplication ? rootApplication : root); confirmationDialog.accepted.connect(clearHistory); confirmationDialog.open(); } } Item { width: executeButton.width; } MaterialToolButton { id: executeButton font.pointSize: 13 text: MaterialIcons.play_arrow ToolTip.text: "Execute Script" onClicked: { root.processScript() } } Item { Layout.fillWidth: true } MaterialToolButton { font.pointSize: 13 text: MaterialIcons.backspace ToolTip.text: "Clear Output Window" onClicked: { output.clear() } } } MSplitView { id: scriptSplitView; Layout.fillHeight: true; Layout.fillWidth: true; orientation: Qt.Horizontal; // Input Text Area -- Holds the input scripts to be executed Rectangle { id: inputArea SplitView.preferredWidth: root.width / 2; color: palette.base ListView { id: lineNumbers property TextMetrics textMetrics: TextMetrics { text: "9999" } model: input.text.split(/\n/g) anchors.left: parent.left anchors.top: parent.top anchors.bottom: parent.bottom width: lineNumbers.textMetrics.boundingRect.width clip: false delegate: Rectangle { width: lineNumbers.width height: lineText.height color: palette.mid Text { id: lineNumber anchors.horizontalCenter: parent.horizontalCenter text: index + 1 font.bold: true color: palette.text } Text { id: lineText width: flickableInput.width text: modelData visible: false wrapMode: Text.WordWrap } } onContentYChanged: { if (!moving) return flickableInput.contentY = contentY } } Flickable { id: flickableInput width: parent.width height: parent.height contentWidth: width contentHeight: input.contentHeight; anchors.left: lineNumbers.right anchors.top: parent.top anchors.right: parent.right anchors.bottom: parent.bottom ScrollBar.vertical: MScrollBar {} TextArea.flickable: TextArea { id: input text: ScriptEditorManager.loadLastScript() font: lineNumbers.textMetrics.font Layout.fillHeight: true Layout.fillWidth: true wrapMode: Text.WordWrap selectByMouse: true padding: 0 onPressed: { root.forceActiveFocus() } Keys.onPressed: function(event) { if ((event.key === Qt.Key_Enter || event.key === Qt.Key_Return) && event.modifiers === Qt.ControlModifier) { root.processScript(input.selectedText) } } } onContentYChanged: { if (lineNumbers.moving) return lineNumbers.contentY = contentY } } } // Output Text Area -- Shows the output for the executed script(s) Rectangle { id: outputArea Layout.fillHeight: true Layout.fillWidth: true color: palette.base Flickable { width: parent.width height: parent.height contentWidth: width contentHeight: output.contentHeight; ScrollBar.vertical: MScrollBar {} TextArea.flickable: TextArea { id: output readOnly: true selectByMouse: true padding: 0 Layout.fillHeight: true Layout.fillWidth: true wrapMode: Text.WordWrap textFormat: Text.RichText } } } // Syntax Highlights for the Input Area for Python Based Syntax PySyntaxHighlighter { id: syntaxHighlighter // The document to highlight textDocument: input.textDocument } } } } ================================================ FILE: meshroom/ui/qml/GraphEditor/StatViewer.qml ================================================ import QtCharts import QtQuick import QtQuick.Controls import QtQuick.Layouts import Charts 1.0 import MaterialIcons 2.2 import Utils 1.0 Item { id: root implicitWidth: 500 implicitHeight: 500 /// Statistics source file property url source property var sourceModified: undefined property var jsonObject property real fileVersion: 0.0 property int nbReads: 1 property real deltaTime: 1 property int nbCores: 0 property int cpuFrequency: 0 property int ramTotal property string ramLabel: "RAM: " property int maxDisplayLength: 500 property int gpuTotalMemory property int gpuMaxAxis: 100 property string gpuName property color textColor: Colors.sysPalette.text readonly property var colors: [ "#f44336", "#e91e63", "#9c27b0", "#673ab7", "#3f51b5", "#2196f3", "#03a9f4", "#00bcd4", "#009688", "#4caf50", "#8bc34a", "#cddc39", "#ffeb3b", "#ffc107", "#ff9800", "#ff5722", "#b71c1c", "#880E4F", "#4A148C", "#311B92", "#1A237E", "#0D47A1", "#01579B", "#006064", "#004D40", "#1B5E20", "#33691E", "#827717", "#F57F17", "#FF6F00", "#E65100", "#BF360C" ] onSourceChanged: { sourceModified = undefined; resetCharts() readSourceFile() } function getPropertyWithDefault(prop, name, defaultValue) { if (prop.hasOwnProperty(name)) { return prop[name] } return defaultValue } Timer { id: reloadTimer interval: root.deltaTime * 60000; running: true; repeat: false onTriggered: readSourceFile() } function readSourceFile() { // Make sure we are trying to load a statistics file if (!Filepath.urlToString(source).endsWith("statistics")) return var xhr = new XMLHttpRequest xhr.open("GET", source) xhr.onreadystatechange = function() { if (xhr.readyState === XMLHttpRequest.DONE && xhr.status == 200) { if (sourceModified === undefined || sourceModified < xhr.getResponseHeader("Last-Modified")) { try { root.jsonObject = JSON.parse(xhr.responseText) } catch(exc) { console.warning("Failed to parse statistics file: " + source) root.jsonObject = {} return } resetCharts() sourceModified = xhr.getResponseHeader("Last-Modified") root.createCharts() reloadTimer.restart() } } } xhr.send() } function resetCharts() { root.fileVersion = 0.0 cpuLegend.clear() cpuChart.removeAllSeries() ramChart.removeAllSeries() gpuChart.removeAllSeries() } function createCharts() { root.deltaTime = getPropertyWithDefault(jsonObject, "interval", 30) / 60.0; root.fileVersion = getPropertyWithDefault(jsonObject, "fileVersion", 0.0) initCpuChart() initRamChart() initGpuChart() } /************************** *** CPU *** **************************/ function initCpuChart() { var categories = [] var categoryCount = 0 var category do { category = jsonObject.computer.curves["cpuUsage." + categoryCount] if (category !== undefined) { categories.push(category) categoryCount++ } } while(category !== undefined) var nbCores = categories.length root.nbCores = nbCores root.cpuFrequency = getPropertyWithDefault(jsonObject.computer, "cpuFreq", -1) root.nbReads = categories[0].length-1 for (var j = 0; j < nbCores; j++) { var lineSerie = cpuChart.createSeries(ChartView.SeriesTypeLine, "CPU" + j, valueCpuX, valueCpuY) if (categories[j].length === 1) { lineSerie.append(0, categories[j][0]) lineSerie.append(root.deltaTime, categories[j][0]) } else { var displayLength = Math.min(maxDisplayLength, categories[j].length) var step = categories[j].length / displayLength for (var kk = 0; kk < displayLength; kk += step) { var k = Math.floor(kk * step) lineSerie.append(k * root.deltaTime, categories[j][k]) } } lineSerie.color = colors[j % colors.length] } var averageLine = cpuChart.createSeries(ChartView.SeriesTypeLine, "AVERAGE", valueCpuX, valueCpuY) var average = [] var displayLengthA = Math.min(maxDisplayLength, categories[0].length) var stepA = categories[0].length / displayLengthA for (var l = 0; l < displayLengthA; l += step) { average.push(0) } for (var m = 0; m < categories.length; m++) { var displayLengthB = Math.min(maxDisplayLength, categories[m].length) var stepB = categories[0].length / displayLengthB for (var nn = 0; nn < displayLengthB; nn++) { var n = Math.floor(nn * stepB) average[nn] += categories[m][n] } } for (var q = 0; q < average.length; q++) { average[q] = average[q] / (categories.length) averageLine.append(q * root.deltaTime * stepA, average[q]) } averageLine.color = colors[colors.length - 1] } function hideOtherCpu(index) { for (var i = 0; i < cpuChart.count; i++) { cpuChart.series(i).visible = false } cpuChart.series(index).visible = true } /************************** *** RAM *** **************************/ function initRamChart() { var ram = getPropertyWithDefault(jsonObject.computer.curves, "ramUsage", -1) root.ramTotal = getPropertyWithDefault(jsonObject.computer, "ramTotal", -1) root.ramLabel = "RAM: " if (root.ramTotal <= 0) { var maxRamPeak = 0 for (var i = 0; i < ram.length; i++) { maxRamPeak = Math.max(maxRamPeak, ram[i]) } root.ramTotal = maxRamPeak root.ramLabel = "RAM Max Peak: " } var ramSerie = ramChart.createSeries(ChartView.SeriesTypeLine, root.ramLabel + root.ramTotal + "GB", valueRamX, valueRamY) if (ram.length === 1) { // Create 2 entries if we have only one input value to create a segment that can be display ramSerie.append(0, ram[0]) ramSerie.append(root.deltaTime, ram[0]) } else { var displayLength = Math.min(maxDisplayLength, ram.length) var step = ram.length / displayLength for(var ii = 0; ii < displayLength; ii++) { var i = Math.floor(ii * step) ramSerie.append(i * root.deltaTime, ram[i]) } } ramSerie.color = colors[10] } /************************** *** GPU *** **************************/ function initGpuChart() { root.gpuTotalMemory = getPropertyWithDefault(jsonObject.computer, "gpuMemoryTotal", 0) root.gpuName = getPropertyWithDefault(jsonObject.computer, "gpuName", "") var gpuUsedMemory = getPropertyWithDefault(jsonObject.computer.curves, "gpuMemoryUsed", 0) var gpuUsed = getPropertyWithDefault(jsonObject.computer.curves, "gpuUsed", 0) var gpuTemperature = getPropertyWithDefault(jsonObject.computer.curves, "gpuTemperature", 0) var gpuUsedSerie = gpuChart.createSeries(ChartView.SeriesTypeLine, "GPU", valueGpuX, valueGpuY) var gpuUsedMemorySerie = gpuChart.createSeries(ChartView.SeriesTypeLine, "Memory", valueGpuX, valueGpuY) var gpuTemperatureSerie = gpuChart.createSeries(ChartView.SeriesTypeLine, "Temperature", valueGpuX, valueGpuY) var gpuMemoryRatio = root.gpuTotalMemory > 0 ? (100 / root.gpuTotalMemory) : 1 if (gpuUsedMemory.length === 1) { gpuUsedSerie.append(0, gpuUsed[0]) gpuUsedSerie.append(1 * root.deltaTime, gpuUsed[0]) gpuUsedMemorySerie.append(0, gpuUsedMemory[0] * gpuMemoryRatio) gpuUsedMemorySerie.append(1 * root.deltaTime, gpuUsedMemory[0] * gpuMemoryRatio) gpuTemperatureSerie.append(0, gpuTemperature[0]) gpuTemperatureSerie.append(1 * root.deltaTime, gpuTemperature[0]) root.gpuMaxAxis = Math.max(gpuMaxAxis, gpuTemperature[0]) } else { var displayLength = Math.min(maxDisplayLength, gpuUsedMemory.length) var step = gpuUsedMemory.length / displayLength for (var ii = 0; ii < displayLength; ii += step) { var i = Math.floor(ii*step) gpuUsedSerie.append(i * root.deltaTime, gpuUsed[i]) gpuUsedMemorySerie.append(i * root.deltaTime, gpuUsedMemory[i] * gpuMemoryRatio) gpuTemperatureSerie.append(i * root.deltaTime, gpuTemperature[i]) root.gpuMaxAxis = Math.max(gpuMaxAxis, gpuTemperature[i]) } } } /************************** *** UI *** **************************/ ScrollView { height: root.height width: root.width ScrollBar.vertical.policy: ScrollBar.AlwaysOn ColumnLayout { width: root.width /************************** *** CPU UI *** **************************/ Button { id: toggleCpuBtn Layout.fillWidth: true text: "Toggle CPU's" state: "closed" onClicked: state === "opened" ? state = "closed" : state = "opened" MaterialLabel { text: MaterialIcons.arrow_drop_down font.pointSize: 14 anchors.right: parent.right } states: [ State { name: "opened" PropertyChanges { target: cpuBtnContainer; visible: true } PropertyChanges { target: toggleCpuBtn; down: true } }, State { name: "closed" PropertyChanges { target: cpuBtnContainer; visible: false } PropertyChanges { target: toggleCpuBtn; down: false } } ] } Item { id: cpuBtnContainer Layout.fillWidth: true implicitHeight: childrenRect.height Layout.leftMargin: 25 RowLayout { width: parent.width anchors.horizontalCenter: parent.horizontalCenter ChartViewCheckBox { id: allCPU text: "ALL" color: textColor checkState: cpuLegend.buttonGroup.checkState leftPadding: 0 onClicked: { var _checked = checked; for (var i = 0; i < cpuChart.count; ++i) { cpuChart.series(i).visible = _checked } } } ChartViewLegend { id: cpuLegend Layout.fillWidth: true Layout.fillHeight: true chartView: cpuChart } } } InteractiveChartView { id: cpuChart Layout.fillWidth: true Layout.preferredHeight: width / 2 margins.top: 0 margins.bottom: 0 antialiasing: true legend.visible: false theme: ChartView.ChartThemeLight backgroundColor: "transparent" plotAreaColor: "transparent" titleColor: textColor visible: (root.fileVersion > 0.0) // Only visible if we have valid information title: "CPU: " + root.nbCores + " cores, " + root.cpuFrequency + "MHz" ValueAxis { id: valueCpuY min: 0 max: 100 titleText: "%" color: textColor gridLineColor: textColor minorGridLineColor: textColor shadesColor: textColor shadesBorderColor: textColor labelsColor: textColor } ValueAxis { id: valueCpuX min: 0 max: root.deltaTime * Math.max(1, root.nbReads) titleText: "Minutes" color: textColor gridLineColor: textColor minorGridLineColor: textColor shadesColor: textColor shadesBorderColor: textColor labelsColor: textColor } } /************************** *** RAM UI *** **************************/ InteractiveChartView { id: ramChart margins.top: 0 margins.bottom: 0 Layout.fillWidth: true Layout.preferredHeight: width / 2 antialiasing: true legend.color: textColor legend.labelColor: textColor legend.visible: false theme: ChartView.ChartThemeLight backgroundColor: "transparent" plotAreaColor: "transparent" titleColor: textColor visible: (root.fileVersion > 0.0) // Only visible if we have valid information title: root.ramLabel + root.ramTotal + "GB" ValueAxis { id: valueRamY min: 0 max: 100 titleText: "%" color: textColor gridLineColor: textColor minorGridLineColor: textColor shadesColor: textColor shadesBorderColor: textColor labelsColor: textColor } ValueAxis { id: valueRamX min: 0 max: root.deltaTime * Math.max(1, root.nbReads) titleText: "Minutes" color: textColor gridLineColor: textColor minorGridLineColor: textColor shadesColor: textColor shadesBorderColor: textColor labelsColor: textColor } } /************************** *** GPU UI *** **************************/ InteractiveChartView { id: gpuChart Layout.fillWidth: true Layout.preferredHeight: width/2 margins.top: 0 margins.bottom: 0 antialiasing: true legend.color: textColor legend.labelColor: textColor theme: ChartView.ChartThemeLight backgroundColor: "transparent" plotAreaColor: "transparent" titleColor: textColor visible: (root.fileVersion >= 2.0) // No GPU information was collected before stats 2.0 fileVersion title: (root.gpuName || root.gpuTotalMemory) ? ("GPU: " + root.gpuName + ", " + root.gpuTotalMemory + "MB") : "No GPU" ValueAxis { id: valueGpuY min: 0 max: root.gpuMaxAxis titleText: "%, °C" color: textColor gridLineColor: textColor minorGridLineColor: textColor shadesColor: textColor shadesBorderColor: textColor labelsColor: textColor } ValueAxis { id: valueGpuX min: 0 max: root.deltaTime * Math.max(1, root.nbReads) titleText: "Minutes" color: textColor gridLineColor: textColor minorGridLineColor: textColor shadesColor: textColor shadesBorderColor: textColor labelsColor: textColor } } } } } ================================================ FILE: meshroom/ui/qml/GraphEditor/TaskManager.qml ================================================ import QtQuick import QtQuick.Controls import QtQuick.Layouts import MaterialIcons 2.2 import Controls 1.0 import Utils 1.0 Item { id: root implicitWidth: 500 implicitHeight: 500 property var uigraph property var taskManager SystemPalette { id: activePalette } property color textColor: Colors.sysPalette.text property color bgColor: Qt.darker(Colors.sysPalette.window, 1.15) property color headBgColor: Qt.darker(Colors.sysPalette.window, 1.30) property color tableBorder: Colors.sysPalette.window property int borderWidth: 3 // Max width for some columns readonly property int maxExecWidth: 200 property var selectedChunk: null function selectNode(node) { uigraph.selectedNode = node } function selectChunk(chunk) { root.selectedChunk = chunk uigraph.selectedChunk = chunk } TextMetrics { id: nbMetrics text: root.taskManager ? root.taskManager.nodes.count : "0" } TextMetrics { id: statusMetrics text: "SUBMITTED" } TextMetrics { id: chunksMetrics text: "Chunks Done" } TextMetrics { id: execMetrics text: "Exec Mode" } TextMetrics { id: progressMetrics text: "Progress" } RowLayout { anchors.fill: parent ColumnLayout { Layout.alignment: Qt.AlignLeft | Qt.AlignTop width: childrenRect.width spacing: 8 // TODO : enable/disable buttons depending on selectedChunk // TODO : Also handle case where uigraph.selectedNode and selectedNode.chunksCreated==false // Task toolbar Rectangle { Layout.preferredWidth: 40 Layout.preferredHeight: taskColumn.height + 8 color: "transparent" border.color: Colors.darkpurple border.width: 2 radius: 8 ColumnLayout { id: taskColumn anchors.centerIn: parent spacing: 2 MaterialToolButton { ToolTip.text: "Stop Task" Layout.alignment: Qt.AlignHCenter enabled: selectedChunk !== null || root.uigraph.selectedNode !== null text: MaterialIcons.stop_circle font.pointSize: 15 onClicked: { if (selectedChunk !== null) { root.uigraph.stopTask(selectedChunk) } else { root.uigraph.stopNode(root.uigraph.selectedNode) } } } MaterialToolButton { ToolTip.text: "Restart Task" Layout.alignment: Qt.AlignHCenter enabled: selectedChunk !== null text: MaterialIcons.replay_circle_filled font.pointSize: 15 onClicked: { uigraph.restartTask(selectedChunk) } } MaterialToolButton { ToolTip.text: "Skip Task" Layout.alignment: Qt.AlignHCenter enabled: selectedChunk !== null text: MaterialIcons.skip_next font.pointSize: 15 onClicked: { uigraph.skipTask(selectedChunk) } } Item { Layout.preferredWidth: 40 Layout.preferredHeight: 50 Text { text: "TASK" anchors.centerIn: parent color: Colors.sysPalette.text font.pixelSize: 11 font.bold: true rotation: -90 transformOrigin: Item.Center } } } } // Job toolbar Rectangle { Layout.preferredWidth: 40 Layout.preferredHeight: jobColumn.height + 8 color: "transparent" border.color: Colors.darkpurple border.width: 2 radius: 8 ColumnLayout { id: jobColumn anchors.centerIn: parent spacing: 2 MaterialToolButton { ToolTip.text: "Pause Job" Layout.alignment: Qt.AlignHCenter enabled: root.uigraph.selectedNode !== null text: MaterialIcons.pause_circle_filled font.pointSize: 15 onClicked: { uigraph.pauseJob(uigraph.selectedNode) } } MaterialToolButton { ToolTip.text: "Resume Job" Layout.alignment: Qt.AlignHCenter enabled: root.uigraph.selectedNode !== null text: MaterialIcons.play_circle_filled font.pointSize: 15 onClicked: { uigraph.resumeJob(uigraph.selectedNode) } } MaterialToolButton { ToolTip.text: "Interrupt Job" Layout.alignment: Qt.AlignHCenter enabled: root.uigraph.selectedNode !== null text: MaterialIcons.stop_circle font.pointSize: 15 onClicked: { uigraph.interruptJob(uigraph.selectedNode) } } MaterialToolButton { ToolTip.text: "Restart All Error Tasks" Layout.alignment: Qt.AlignHCenter enabled: root.uigraph.selectedNode !== null text: MaterialIcons.replay_circle_filled font.pointSize: 15 onClicked: { uigraph.restartJobErrorTasks(uigraph.selectedNode) } } Item { Layout.preferredWidth: 40 Layout.preferredHeight: 40 Text { text: "JOB" anchors.centerIn: parent color: Colors.sysPalette.text font.pixelSize: 11 font.bold: true rotation: -90 transformOrigin: Item.Center } } } } } ListView { id: taskList Layout.alignment: Qt.AlignLeft | Qt.AlignTop Layout.fillWidth: true Layout.fillHeight: true ScrollBar.vertical: MScrollBar {} model: root.taskManager ? root.taskManager.nodes : null spacing: 3 headerPositioning: ListView.OverlayHeader header: RowLayout { height: 30 spacing: 3 width: parent.width z: 2 Label { text: qsTr("Nb") Layout.preferredWidth: nbMetrics.width + 20 Layout.preferredHeight: parent.height horizontalAlignment: Label.AlignHCenter verticalAlignment: Label.AlignVCenter background: Rectangle { color: headBgColor } } Label { text: qsTr("Node") Layout.preferredWidth: 200 Layout.preferredHeight: parent.height horizontalAlignment: Label.AlignHCenter verticalAlignment: Label.AlignVCenter background: Rectangle { color: headBgColor } } Label { text: qsTr("State") Layout.preferredWidth: statusMetrics.width + 20 Layout.preferredHeight: parent.height horizontalAlignment: Label.AlignHCenter verticalAlignment: Label.AlignVCenter background: Rectangle { color: headBgColor } } Label { text: qsTr("Chunks Done") Layout.preferredWidth: chunksMetrics.width + 20 Layout.preferredHeight: parent.height horizontalAlignment: Label.AlignHCenter verticalAlignment: Label.AlignVCenter background: Rectangle { color: headBgColor } } Label { text: qsTr("Exec Mode") Layout.preferredWidth: execMetrics.width + 60 Layout.preferredHeight: parent.height horizontalAlignment: Label.AlignHCenter verticalAlignment: Label.AlignVCenter background: Rectangle { color: headBgColor } } Label { text: qsTr("Progress") Layout.fillWidth: true Layout.minimumWidth: progressMetrics.width + 20 Layout.preferredHeight: parent.height horizontalAlignment: Label.AlignHCenter verticalAlignment: Label.AlignVCenter background: Rectangle { color: headBgColor } } } delegate: RowLayout { width: ListView.view.width height: 18 spacing: 3 function getNbFinishedChunks(chunks) { var nbSuccess = 0 for (var i = 0; i < chunks.count; i++) { if (chunks.at(i).statusName === "SUCCESS") { nbSuccess += 1 } } return nbSuccess } Label { text: index + 1 Layout.preferredWidth: nbMetrics.width + 20 Layout.preferredHeight: parent.height horizontalAlignment: Label.AlignHCenter verticalAlignment: Label.AlignVCenter color: object === uigraph.selectedNode ? Colors.sysPalette.window : Colors.sysPalette.text background: Rectangle { color: object === uigraph.selectedNode ? Colors.sysPalette.text : bgColor } MouseArea { anchors.fill: parent onPressed: { selectNode(object) } } } Label { text: object.label elide: Text.ElideRight Layout.preferredWidth: 200 Layout.preferredHeight: parent.height horizontalAlignment: Label.AlignHCenter verticalAlignment: Label.AlignVCenter color: object === uigraph.selectedNode ? Colors.sysPalette.window : Colors.sysPalette.text background: Rectangle { color: object === uigraph.selectedNode ? Colors.sysPalette.text : bgColor } MouseArea { anchors.fill: parent acceptedButtons: Qt.LeftButton | Qt.RightButton onPressed: (mouse) => { if (mouse.button === Qt.LeftButton) { selectNode(object) } else if (mouse.button === Qt.RightButton) { contextMenu.popup() } } Menu { id: contextMenu MenuItem { text: "Open Folder" height: visible ? implicitHeight : 0 onTriggered: Qt.openUrlExternally(Filepath.stringToUrl(object.internalFolder)) } } } } Label { text: object.globalStatus Layout.preferredWidth: statusMetrics.width + 20 Layout.preferredHeight: parent.height horizontalAlignment: Label.AlignHCenter verticalAlignment: Label.AlignVCenter color: object === uigraph.selectedNode ? Colors.sysPalette.window : Colors.sysPalette.text background: Rectangle { color: object === uigraph.selectedNode ? Colors.sysPalette.text : bgColor } MouseArea { anchors.fill: parent onPressed: { selectNode(object) } } } Label { text: getNbFinishedChunks(object.chunks) + "/" + object.chunks.count Layout.preferredWidth: chunksMetrics.width + 20 Layout.preferredHeight: parent.height horizontalAlignment: Label.AlignHCenter verticalAlignment: Label.AlignVCenter color: object === uigraph.selectedNode ? Colors.sysPalette.window : Colors.sysPalette.text background: Rectangle { color: object === uigraph.selectedNode ? Colors.sysPalette.text : bgColor } MouseArea { anchors.fill: parent onPressed: { selectNode(object) } } } Label { text: object.jobName elide: Text.ElideRight Layout.preferredWidth: execMetrics.width + 60 Layout.preferredHeight: parent.height horizontalAlignment: Label.AlignHCenter verticalAlignment: Label.AlignVCenter color: object === uigraph.selectedNode ? Colors.sysPalette.window : Colors.sysPalette.text background: Rectangle { color: object === uigraph.selectedNode ? Colors.sysPalette.text : bgColor } MouseArea { anchors.fill: parent onPressed: { selectNode(object) } } } Item { Layout.fillWidth: true Layout.minimumWidth: progressMetrics.width + 20 Layout.preferredHeight: parent.height ListView { id: chunkList width: parent.width height: parent.height orientation: ListView.Horizontal model: object.chunks property var node: object spacing: 3 delegate: Loader { id: chunkDelegate width: ListView.view.model ? (ListView.view.width - (ListView.view.model.count - 1) * chunkList.spacing) / ListView.view.model.count : 0 height: ListView.view.height sourceComponent: Label { anchors.fill: parent background: Rectangle { color: Colors.getChunkColor(object, {"NONE": bgColor}) radius: 3 border.width: 2 border.color: (root.selectedChunk == object) ? Qt.darker(color, 1.3) : "transparent" } MouseArea { anchors.fill: parent onPressed: { selectNode(chunkList.node) selectChunk(object) } } } } // Placeholder for uninitialized chunks Label { enabled: chunkList.model.count == 0 visible: enabled anchors.fill: parent background: Rectangle { color: Colors.getNodeColor(chunkList.node, {"NONE": Colors.darkpurple}) radius: 3 border.width: 2 border.color: (chunkList.node === uigraph.selectedNode) ? Qt.lighter(color, 1.3) : "transparent" } MouseArea { anchors.fill: parent onPressed: { selectNode(chunkList.node) selectChunk(null) } } } } } } } } } ================================================ FILE: meshroom/ui/qml/GraphEditor/qmldir ================================================ module GraphEditor GraphEditor 1.0 GraphEditor.qml NodeEditor 1.0 NodeEditor.qml Node 1.0 Node.qml NodeChunks 1.0 NodeChunks.qml Edge 1.0 Edge.qml Backdrop 1.0 Backdrop.qml AttributePin 1.0 AttributePin.qml AttributeEditor 1.0 AttributeEditor.qml AttributeItemDelegate 1.0 AttributeItemDelegate.qml CompatibilityBadge 1.0 CompatibilityBadge.qml CompatibilityManager 1.0 CompatibilityManager.qml singleton GraphEditorSettings 1.0 GraphEditorSettings.qml TaskManager 1.0 TaskManager.qml ScriptEditor 1.0 ScriptEditor.qml NodeFileBrowser 1.0 NodeFileBrowser.qml ================================================ FILE: meshroom/ui/qml/Homepage.qml ================================================ import QtQuick import QtQuick.Controls import QtQuick.Layouts import Utils 1.0 import MaterialIcons 2.2 import Controls 1.0 Page { id: root onVisibleChanged: { logo.playing = false if (visible) { logo.playing = true } } MSplitView { id: splitView orientation: Qt.Horizontal anchors.fill: parent Item { SplitView.minimumWidth: 250 SplitView.preferredWidth: 330 SplitView.maximumWidth: 500 ColumnLayout { id: leftColumn anchors.fill: parent spacing: 20 AnimatedImage { id: logo property var ratio: sourceSize.width / sourceSize.height Layout.fillWidth: true fillMode: Image.PreserveAspectFit // Enforce aspect ratio of the component, as the fillMode does not do the job Layout.preferredHeight: width / ratio smooth: true source: "../img/meshroom-anim-once.gif" } ColumnLayout { Layout.fillWidth: true Layout.fillHeight: true Layout.margins: 5 Layout.leftMargin: 20 property real buttonFontSize: 14 MaterialToolLabelButton { id: manualButton iconText: MaterialIcons.open_in_new label: "Manual" font.pointSize: parent.buttonFontSize Layout.fillWidth: true onClicked: Qt.openUrlExternally("https://meshroom-manual.readthedocs.io") } MaterialToolLabelButton { id: releaseNotesButton iconText: MaterialIcons.open_in_new label: "Release Notes" font.pointSize: parent.buttonFontSize Layout.fillWidth: true onClicked: Qt.openUrlExternally("https://github.com/alicevision/Meshroom/blob/develop/CHANGES.md") } MaterialToolLabelButton { id: websiteButton iconText: MaterialIcons.open_in_new label: "Website" font.pointSize: parent.buttonFontSize Layout.fillWidth: true onClicked: Qt.openUrlExternally("https://alicevision.org") } } ColumnLayout { id: sponsors Layout.fillWidth: true Layout.alignment: Qt.AlignHCenter spacing: 5 Item { // Empty area that expands Layout.fillWidth: true Layout.fillHeight: true } Label { Layout.alignment: Qt.AlignHCenter text: "Sponsors" } RowLayout { id: brandsRow Layout.fillWidth: true Layout.alignment: Qt.AlignHCenter spacing: 20 Image { source: "../img/MPC.png" MouseArea { anchors.fill: parent cursorShape: Qt.PointingHandCursor onClicked: Qt.openUrlExternally("https://www.mpcvfx.com/") hoverEnabled: true ToolTip.visible: containsMouse ToolTip.text: "MPC - Moving Picture Company" } } Image { source: "../img/MILL.png" MouseArea { anchors.fill: parent cursorShape: Qt.PointingHandCursor onClicked: Qt.openUrlExternally("https://www.themill.com/") hoverEnabled: true ToolTip.visible: containsMouse ToolTip.text: "The Mill" } } Image { source: "../img/MIKROS.png" MouseArea { anchors.fill: parent cursorShape: Qt.PointingHandCursor onClicked: Qt.openUrlExternally("https://www.mikrosanimation.com/") hoverEnabled: true ToolTip.visible: containsMouse ToolTip.text: "Mikros Animation" } } } RowLayout { id: academicRow Layout.fillWidth: true Layout.alignment: Qt.AlignHCenter spacing: 28 Image { source: "../img/logo_IRIT.png" MouseArea { anchors.fill: parent cursorShape: Qt.PointingHandCursor onClicked: Qt.openUrlExternally("https://www.irit.fr/en/departement/dep-hpc-simulation-optimization/reva-team") hoverEnabled: true ToolTip.visible: containsMouse ToolTip.text: "IRIT - Institut de Recherche en Informatique de Toulouse" } } Image { source: "../img/logo_CTU.png" MouseArea { anchors.fill: parent cursorShape: Qt.PointingHandCursor onClicked: Qt.openUrlExternally("http://aag.ciirc.cvut.cz") hoverEnabled: true ToolTip.visible: containsMouse ToolTip.text: "CTU - Czech Technical University in Prague" } } Image { source: "../img/logo_uio.png" MouseArea { anchors.fill: parent cursorShape: Qt.PointingHandCursor onClicked: Qt.openUrlExternally("https://www.mn.uio.no/ifi/english/about/organisation/dis") hoverEnabled: true ToolTip.visible: containsMouse ToolTip.text: "UiO - University of Oslo" } } Image { source: "../img/logo_enpc.png" MouseArea { anchors.fill: parent cursorShape: Qt.PointingHandCursor onClicked: Qt.openUrlExternally("https://imagine-lab.enpc.fr") hoverEnabled: true ToolTip.visible: containsMouse ToolTip.text: "ENPC - Ecole des Ponts ParisTech" } } } MaterialToolLabelButton { Layout.topMargin: 10 Layout.bottomMargin: 10 Layout.alignment: Qt.AlignHCenter label: "Support AliceVision" iconText: MaterialIcons.favorite // Slightly "extend" the clickable area for the button while preserving the centered layout iconItem.leftPadding: 15 labelItem.rightPadding: 15 onClicked: Qt.openUrlExternally("https://alicevision.org/association/#donate") } } } } ColumnLayout { id: rightColumn SplitView.minimumWidth: 300 SplitView.fillWidth: true TabPanel { id: tabPanel tabs: ["Pipelines", "Projects"] Layout.fillWidth: true Layout.fillHeight: true ListView { id: pipelinesListView visible: tabPanel.currentTab === 0 anchors.fill: parent anchors.margins: 10 model: [{ "name": "New Empty Project", "path": null }].concat(MeshroomApp.pipelineTemplateFiles) delegate: Button { id: pipelineDelegate padding: 10 width: pipelinesListView.width contentItem: Label { id: pipeline horizontalAlignment: Text.AlignLeft verticalAlignment: Text.AlignVCenter text: modelData["name"] } Connections { target: pipelineDelegate function onClicked() { // Open pipeline mainStack.push("Application.qml") _currentScene.new(modelData["path"]) } } } } GridView { id: homepageGridView visible: tabPanel.currentTab === 1 anchors.fill: parent anchors.topMargin: cellHeight * 0.1 cellWidth: 195 cellHeight: cellWidth anchors.margins: 10 model: { // Request latest thumbnail paths if (mainStack.currentItem instanceof Homepage) MeshroomApp.updateRecentProjectFilesThumbnails() return [{"path": null, "thumbnail": null, "status": null}].concat(MeshroomApp.recentProjectFiles) } // Update grid item when corresponding thumbnail is computed Connections { target: ThumbnailCache function onThumbnailCreated(imgSource, callerID) { let item = homepageGridView.itemAtIndex(callerID); // item is an Image if (item && item.source === imgSource) { item.updateThumbnail() return } // fallback in case the Image cellID changed for (let idx = 0; idx < homepageGridView.count; idx++) { item = homepageGridView.itemAtIndex(idx) if (item && item.source === imgSource) { item.updateThumbnail() } } } } delegate: Column { id: projectContent width: homepageGridView.cellWidth height: homepageGridView.cellHeight property var source: modelData["thumbnail"] ? Filepath.stringToUrl(modelData["thumbnail"]) : "" function updateThumbnail() { thumbnail.source = ThumbnailCache.thumbnail(source, homepageGridView.currentIndex) } onSourceChanged: updateThumbnail() Button { id: projectDelegate height: homepageGridView.cellHeight * 0.95 - project.height width: homepageGridView.cellWidth * 0.9 // Handle case where the file is missing property bool fileExists: modelData["status"] != 0 opacity: fileExists ? 1.0 : 0.3 ToolTip.visible: hovered ToolTip.text: modelData["path"] ? modelData["path"] : "Open browser to select a project file" font.family: MaterialIcons.fontFamily font.pointSize: 24 text: modelData["path"] ? (modelData["thumbnail"] ? "" : MaterialIcons.description) : MaterialIcons.folder_open MouseArea { anchors.fill: parent acceptedButtons: Qt.LeftButton | Qt.RightButton hoverEnabled: true onClicked: function(mouse) { if (mouse.button === Qt.RightButton) { if (!modelData["path"]) { return } projectContextMenu.x = mouse.x projectContextMenu.y = mouse.y projectContextMenu.open() return } if (!modelData["path"]) { initFileDialogFolder(openFileDialog) openFileDialog.open() } else { // Open project mainStack.push("Application.qml") if (_currentScene.load(modelData["path"])) { MeshroomApp.addRecentProjectFile(modelData["path"]) } } } } Menu { id: projectContextMenu MenuItem { enabled: projectDelegate.fileExists text: "Open" onTriggered: { if (_currentScene.load(modelData["path"])) { mainStack.push("Application.qml") MeshroomApp.addRecentProjectFile(modelData["path"]) } } } MenuItem { text: "Copy Path" onTriggered: { Clipboard.clear() Clipboard.setText(modelData["path"]) } } MenuItem { text: "Delete" onTriggered: { MeshroomApp.removeRecentProjectFile(modelData["path"]) } } } Image { id: thumbnail visible: modelData["thumbnail"] cache: false asynchronous: true fillMode: Image.PreserveAspectCrop width: projectDelegate.width height: projectDelegate.height } BusyIndicator { anchors.centerIn: parent running: homepageGridView.visible && modelData["thumbnail"] && thumbnail.status != Image.Ready visible: running } } Label { id: project anchors.horizontalCenter: projectDelegate.horizontalCenter horizontalAlignment: Text.AlignHCenter width: projectDelegate.width elide: Text.ElideMiddle text: modelData["path"] ? Filepath.basename(modelData["path"]) : "Open Project" maximumLineCount: 1 font.pointSize: 10 } } } } } } } ================================================ FILE: meshroom/ui/qml/ImageGallery/ImageBadge.qml ================================================ import QtQuick import MaterialIcons 2.2 import Utils 1.0 /** * ImageBadge is a preset MaterialLabel to display an icon bagdge on an image. */ MaterialLabel { id: root font.pointSize: 10 padding: 2 background: Rectangle { color: Colors.sysPalette.window opacity: 0.6 } } ================================================ FILE: meshroom/ui/qml/ImageGallery/ImageDelegate.qml ================================================ import QtQuick import QtQuick.Controls import QtQuick.Layouts import MaterialIcons 2.2 import Utils 1.0 /** * ImageDelegate for a Viewpoint object. */ Item { id: root property variant viewpoint property int cellID: -1 property alias source: _viewpoint.source property alias metadata: _viewpoint.metadata property bool readOnly: false property bool displayViewId: false property bool displayThumbnail: true property int layoutMode: 0 // 0: grid, 1: list property variant parentModel property int selectedIndex: parentModel ? parentModel.selectedIndex : -1 property bool isCurrentItem: cellID >= 0 && cellID === selectedIndex signal pressed(var mouse) signal removeRequest() signal removeAllImagesRequest() default property alias children: imageMA.children // Internal properties to hold thumbnail source & loading status property url thumbnailSource: "" property int thumbnailStatus: Image.Null // Retrieve viewpoints inner data QtObject { id: _viewpoint property url source: viewpoint ? Filepath.stringToUrl(viewpoint.get("path").value) : '' property int viewId: viewpoint ? viewpoint.get("viewId").value : -1 property string metadataStr: viewpoint ? viewpoint.get("metadata").value : '' property var metadata: metadataStr ? JSON.parse(viewpoint.get("metadata").value) : {} } // Update thumbnail location // Can be called from the GridView when a new thumbnail has been written on disk function updateThumbnail() { if (!displayThumbnail) return root.thumbnailSource = ThumbnailCache.thumbnail(root.source, root.cellID) } onSourceChanged: { updateThumbnail() } onDisplayThumbnailChanged: { if (displayThumbnail) updateThumbnail() else root.thumbnailSource = "" } // Send a new request after 5 seconds if thumbnail is not loaded // This is meant to avoid deadlocks in case there is a synchronization problem Timer { interval: 5000 running: true onTriggered: { if (root.thumbnailStatus == Image.Null) { updateThumbnail() } } } MouseArea { id: imageMA anchors.fill: parent anchors.margins: 6 hoverEnabled: true acceptedButtons: Qt.LeftButton | Qt.RightButton onPressed: function(mouse) { if (mouse.button == Qt.RightButton) imageMenu.popup() root.pressed(mouse) } Menu { id: imageMenu MenuItem { text: "Show Containing Folder" onClicked: { Qt.openUrlExternally(Filepath.dirname(root.source)) } } MenuItem { text: "Remove" enabled: !root.readOnly onClicked: removeRequest() } MenuItem { text: "Remove All Images" enabled: !root.readOnly onClicked: removeAllImagesRequest() } MenuItem { text: "Define As Center Image" property var activeNode: _currentScene ? _currentScene.activeNodes.get("SfMTransform").node : null enabled: !root.readOnly && _viewpoint.viewId != -1 && _currentScene && activeNode onClicked: _currentScene.setAttribute(activeNode.attribute("transformation"), _viewpoint.viewId.toString()) } Menu { id: sfmSetPairMenu title: "SfM: Define Initial Pair" property var activeNode: _currentScene ? _currentScene.activeNodes.get("StructureFromMotion").node : null enabled: !root.readOnly && _viewpoint.viewId != -1 && _currentScene && activeNode MenuItem { text: "A" onClicked: _currentScene.setAttribute(sfmSetPairMenu.activeNode.attribute("initialPairA"), _viewpoint.viewId.toString()) } MenuItem { text: "B" onClicked: _currentScene.setAttribute(sfmSetPairMenu.activeNode.attribute("initialPairB"), _viewpoint.viewId.toString()) } } } // Switch from the grid component (column layout) to the list component (row layout) Loader { id: itemDelegate anchors.fill: parent sourceComponent: root.layoutMode === 0 ? gridDelegate : listDelegate } Component { id: gridDelegate ColumnLayout { anchors.fill: parent spacing: 0 // Image thumbnail and background Rectangle { color: Qt.darker(grid_imageLabel.palette.base, 1.15) Layout.fillHeight: true Layout.fillWidth: true visible: root.displayThumbnail border.color: isCurrentItem ? grid_imageLabel.palette.highlight : Qt.darker(grid_imageLabel.palette.highlight) border.width: imageMA.containsMouse || root.isCurrentItem ? 2 : 0 Image { id: grid_thumbnail anchors.fill: parent anchors.margins: 4 source: root.thumbnailSource asynchronous: true autoTransform: true fillMode: Image.PreserveAspectFit smooth: false cache: false onStatusChanged: root.thumbnailStatus = status } BusyIndicator { anchors.centerIn: parent running: grid_thumbnail.status != Image.Ready } } // Placeholder icon shown when thumbnails are disabled Label { Layout.fillHeight: true Layout.fillWidth: true visible: !root.displayThumbnail horizontalAlignment: Text.AlignHCenter verticalAlignment: Text.AlignVCenter text: MaterialIcons.image font.family: MaterialIcons.fontFamily font.pointSize: 16 color: palette.mid } // Image basename Label { id: grid_imageLabel Layout.fillWidth: true padding: 2 font.pointSize: 8 elide: Text.ElideMiddle horizontalAlignment: Text.AlignHCenter text: Filepath.basename(root.source) background: Rectangle { color: root.isCurrentItem ? parent.palette.highlight : "transparent" } } // Image viewId Loader { active: displayViewId Layout.fillWidth: true visible: active sourceComponent: Label { padding: grid_imageLabel.padding font.pointSize: grid_imageLabel.font.pointSize elide: grid_imageLabel.elide horizontalAlignment: grid_imageLabel.horizontalAlignment text: _viewpoint.viewId background: Rectangle { color: grid_imageLabel.background.color } } } } } Component { id: listDelegate RowLayout { anchors.fill: parent spacing: 4 // Image thumbnail and background Rectangle { color: Qt.darker(list_imageLabel.palette.base, 1.15) Layout.fillHeight: true Layout.preferredWidth: 100 visible: root.displayThumbnail border.color: isCurrentItem ? list_imageLabel.palette.highlight : Qt.darker(list_imageLabel.palette.highlight) border.width: imageMA.containsMouse || root.isCurrentItem ? 2 : 0 Image { id: list_thumbnail anchors.fill: parent anchors.margins: 4 source: root.thumbnailSource asynchronous: true autoTransform: true fillMode: Image.PreserveAspectFit smooth: false cache: false onStatusChanged: root.thumbnailStatus = status } BusyIndicator { anchors.centerIn: parent running: list_thumbnail.status != Image.Ready } } // Placeholder icon shown when thumbnails are disabled Label { Layout.fillHeight: true visible: !root.displayThumbnail horizontalAlignment: Text.AlignHCenter verticalAlignment: Text.AlignVCenter text: MaterialIcons.image font.family: MaterialIcons.fontFamily font.pointSize: 14 color: palette.mid } ColumnLayout { Layout.fillWidth: true Layout.fillHeight: true spacing: 0 // Image basename Label { id: list_imageLabel Layout.fillWidth: true Layout.fillHeight: true padding: 4 font.pointSize: 8 elide: Text.ElideMiddle horizontalAlignment: Text.AlignLeft verticalAlignment: Text.AlignVCenter text: Filepath.basename(root.source) background: Rectangle { color: root.isCurrentItem ? parent.palette.highlight : "transparent" } } // Image viewId Loader { active: root.displayViewId Layout.fillWidth: true Layout.fillHeight: active visible: active sourceComponent: Label { padding: list_imageLabel.padding font.pointSize: list_imageLabel.font.pointSize elide: list_imageLabel.elide horizontalAlignment: list_imageLabel.horizontalAlignment verticalAlignment: list_imageLabel.verticalAlignment text: _viewpoint.viewId background: Rectangle { color: list_imageLabel.background.color } } } } } } } } ================================================ FILE: meshroom/ui/qml/ImageGallery/ImageGallery.qml ================================================ import QtQuick import QtQuick.Controls import QtQuick.Layouts import QtQml.Models import Qt.labs.qmlmodels import Controls 1.0 import MaterialIcons 2.2 import Utils 1.0 /** * ImageGallery displays as a grid of Images a model containing Viewpoints objects. * It manages a model of multiple CameraInit nodes as individual groups. */ Panel { id: root property variant cameraInits property variant cameraInit property int cameraInitIndex property variant tempCameraInit readonly property var currentItem: layoutLoader.item ? layoutLoader.item.currentItem : null readonly property string currentItemSource: currentItem ? currentItem.source : "" readonly property var currentItemMetadata: currentItem ? currentItem.metadata : undefined readonly property int centerViewId: (_currentScene && _currentScene.sfmTransform) ? parseInt(_currentScene.sfmTransform.attribute("transformation").value) : 0 readonly property var galleryGrid: layoutLoader.item // This now references the loaded view (grid or list) property int defaultCellSize: 160 property bool readOnly: false enum LayoutModes { Grid=0, List=1 } property int displayMode: ImageGallery.LayoutModes.Grid property var filesByType: ({}) property int nbMeshroomScenes: 0 property int nbDraggedFiles: 0 signal removeImageRequest(var attribute) signal allViewpointsCleared() signal filesDropped(var drop) title: "Image Gallery" implicitWidth: (root.defaultCellSize + 2) * 2 Connections { target: _currentScene function onCameraInitChanged() { nodesCB.currentIndex = root.cameraInitIndex } } QtObject { id: m property variant currentCameraInit: _currentScene && _currentScene.tempCameraInit ? _currentScene.tempCameraInit : root.cameraInit property variant viewpoints: currentCameraInit ? currentCameraInit.attribute('viewpoints').value : undefined property variant intrinsics: currentCameraInit ? currentCameraInit.attribute('intrinsics').value : undefined property bool readOnly: ((_currentScene && currentCameraInit) ? currentCameraInit.locked : root.readOnly) || displayHDR.checked onViewpointsChanged: { ThumbnailCache.clearRequests() } onIntrinsicsChanged: { parseIntr() } } property variant parsedIntrinsic property int numberOfIntrinsics: m.intrinsics ? m.intrinsics.count : 0 onNumberOfIntrinsicsChanged: { parseIntr() } function changeCurrentIndex(newIndex) { _currentScene.cameraInitIndex = newIndex } function populate_model() { if (!intrinsicModel.ready) { // If the TableModel is not done being instantiated, do nothing return } intrinsicModel.clear() for (var intr in parsedIntrinsic) { intrinsicModel.appendRow(parsedIntrinsic[intr]) } } function parseIntr() { parsedIntrinsic = [] if (!m.intrinsics) { return } // Loop through all intrinsics for (var i = 0; i < m.intrinsics.count; ++i) { var intrinsic = {} // Loop through all attributes for (var j = 0; j < m.intrinsics.at(i).value.count; ++j) { var currentAttribute = m.intrinsics.at(i).value.at(j) if (currentAttribute.type === "GroupAttribute") { for (var k = 0; k < currentAttribute.value.count; ++k) { intrinsic[currentAttribute.name + "." + currentAttribute.value.at(k).name] = currentAttribute.value.at(k) } } else if (currentAttribute.type === "ListAttribute") { // Not needed for now } else { intrinsic[currentAttribute.name] = currentAttribute } } // Table Model needs to contain an entry for each column. // In case of old file formats, some intrinsic keys that we display may not exist in the model. // So, here we create an empty entry to enforce that the key exists in the model. for (var n = 0; n < intrinsicModel.columnNames.length; ++n) { var name = intrinsicModel.columnNames[n] if (!(name in intrinsic)) { intrinsic[name] = {} } } parsedIntrinsic[i] = intrinsic } populate_model() } function toggleDisplayMode() { displayMode = displayMode === ImageGallery.LayoutModes.Grid ? ImageGallery.LayoutModes.List : ImageGallery.LayoutModes.Grid } headerBar: RowLayout { SearchBar { id: searchBar toggle: true // Enable toggling the actual text field by the search button Layout.minimumWidth: searchBar.width maxWidth: 150 } MaterialToolButton { text: root.displayMode === ImageGallery.LayoutModes.Grid ? MaterialIcons.view_list : MaterialIcons.view_module font.pointSize: 11 padding: 2 ToolTip.text: "Switch the layout to " + root.displayMode === ImageGallery.LayoutModes.Grid ? "List" : "Grid" ToolTip.visible: hovered onClicked: root.toggleDisplayMode() } MaterialToolButton { text: MaterialIcons.more_vert font.pointSize: 11 padding: 2 checkable: true checked: galleryMenu.visible onClicked: galleryMenu.open() Menu { id: galleryMenu y: parent.height x: -width + parent.width MenuItem { text: "Edit Sensor Database..." onTriggered: { sensorDBDialog.open() } } Menu { title: "Advanced" Action { id: displayViewIdsAction text: "Display View IDs" checkable: true } } } } } SensorDBDialog { id: sensorDBDialog sensorDatabase: cameraInit ? Filepath.stringToUrl(cameraInit.attribute("sensorDatabase").evalValue) : "" readOnly: _currentScene ? _currentScene.computing : false onUpdateIntrinsicsRequest: _currentScene.rebuildIntrinsics(cameraInit) } SortFilterDelegateModel { id: sortedModel model: m.viewpoints sortRole: "path.basename" filters: displayViewIdsAction.checked ? filtersWithViewIds : filtersBasic property var filtersBasic: [ {role: "path", value: searchBar.text}, {role: "viewId.isReconstructed", value: reconstructionFilter} ] property var filtersWithViewIds: [ [ {role: "path", value: searchBar.text}, {role: "viewId.asString", value: searchBar.text} ], {role: "viewId.isReconstructed", value: reconstructionFilter} ] property var reconstructionFilter: undefined // Override modelData to return basename of viewpoint's path for sorting function modelData(item, roleName_) { var roleNameAndCmd = roleName_.split(".") var roleName = roleName_ var cmd = "" if (roleNameAndCmd.length >= 2) { roleName = roleNameAndCmd[0] cmd = roleNameAndCmd[1] } if (cmd === "isReconstructed") return _currentScene.isReconstructed(item.model.object); var value = item.model.object.childAttribute(roleName).value; if (cmd === "basename") return Filepath.basename(value); if (cmd === "asString") return value.toString(); return value } property int selectedIndex: -1 delegate: ImageDelegate { id: imageDelegate layoutMode: root.displayMode viewpoint: object.value cellID: DelegateModel.filteredIndex width: layoutLoader.item ? (displayMode === ImageGallery.LayoutModes.List ? layoutLoader.item.width : layoutLoader.item.cellWidth) : 0 height: layoutLoader.item ? layoutLoader.item.cellHeight : 0 readOnly: m.readOnly displayViewId: displayViewIdsAction.checked displayThumbnail: thumbnailSizeSlider.value > thumbnailSizeSlider.from visible: !intrinsicsFilterButton.checked parentModel: sortedModel onPressed: { if (layoutLoader.item) { layoutLoader.item.currentIndex = DelegateModel.filteredIndex sortedModel.selectedIndex = DelegateModel.filteredIndex } } function sendRemoveRequest() { if (readOnly) return root.removeImageRequest(object) // If the last image has been removed, make sure the viewpoints and intrinsics are reset if (m.viewpoints.count === 0) root.allViewpointsCleared() } function removeAllImages() { _currentScene.removeAllImages() _currentScene.selectedViewId = "-1" } onRemoveRequest: sendRemoveRequest() Keys.onPressed: function(event) { if (event.key === Qt.Key_Delete && event.modifiers === Qt.ShiftModifier) { removeAllImages() } else if (event.key === Qt.Key_Delete) { sendRemoveRequest() } } onRemoveAllImagesRequest: { removeAllImages() } RowLayout { anchors.top: parent.top anchors.left: parent.left anchors.right: parent.right anchors.margins: 2 spacing: 2 property bool valid: Qt.isQtObject(object) // object can be evaluated to null at some point during creation/deletion property bool inViews: valid && _currentScene && _currentScene.sfmReport && _currentScene.isInViews(object) // Camera Initialization indicator IntrinsicsIndicator { intrinsic: parent.valid && _currentScene ? _currentScene.getIntrinsic(object) : null metadata: imageDelegate.metadata } // Rig indicator Loader { id: rigIndicator property int rigId: parent.valid ? object.childAttribute("rigId").value : -1 active: rigId >= 0 sourceComponent: ImageBadge { property int rigSubPoseId: model.object.childAttribute("subPoseId").value text: MaterialIcons.link ToolTip.text: "Rig: Initialized
" + "Rig ID: " + rigIndicator.rigId + "
" + "SubPose: " + rigSubPoseId } } // Center of SfMTransform Loader { id: sfmTransformIndicator active: viewpoint && (viewpoint.get("viewId").value === centerViewId) sourceComponent: ImageBadge { text: MaterialIcons.gamepad ToolTip.text: "Camera used to define the center of the scene." } } Item { Layout.fillWidth: true } // Reconstruction status indicator Loader { active: parent.inViews visible: active sourceComponent: ImageBadge { property bool reconstructed: _currentScene.sfmReport && _currentScene.isReconstructed(model.object) text: reconstructed ? MaterialIcons.videocam : MaterialIcons.videocam_off color: reconstructed ? Colors.green : Colors.red ToolTip.text: "Camera: " + (reconstructed ? "" : "Not ") + "Reconstructed" } } } } } ColumnLayout { anchors.fill: parent spacing: 4 Loader { id: layoutLoader Layout.fillWidth: true Layout.fillHeight: true visible: !intrinsicsFilterButton.checked sourceComponent: root.displayMode === ImageGallery.LayoutModes.Grid ? gridViewComponent : listViewComponent onLoaded: { if (item) { // Pass necessary properties to the loaded component item.m = m item.gallery = root item.searchBar = searchBar item.intrinsicsFilterButton = intrinsicsFilterButton item.tempCameraInit = tempCameraInit item.errorDialog = errorDialog item.sortedModel = sortedModel item.thumbnailSizeSlider = thumbnailSizeSlider // Connect signals item.removeImageRequest.connect(root.removeImageRequest) item.allViewpointsCleared.connect(root.allViewpointsCleared) // Restore currentIndex (before connecting signals to avoid unwanted selection change) item.currentIndex = sortedModel.selectedIndex // Don't scroll yet because we must make sure the layout is loaded first scrollTimer.restart() } } } // Add a timer with a small delay so that we scroll after loading the layout Timer { id: scrollTimer interval: 25 repeat: false onTriggered: { if (layoutLoader.item && _currentScene.selectedViewId > -1) { layoutLoader.item.updateCurrentIndexFromSelectionViewId() // Use another short delay for the actual scroll Qt.callLater(function() { if (layoutLoader.item && layoutLoader.item.currentIndex >= 0) { layoutLoader.item.makeCurrentItemVisible() } }) } } } Component { id: gridViewComponent ImageGridView { id: gridView } } Component { id: listViewComponent ImageListView { id: listView } } Item { Layout.fillWidth: true Layout.fillHeight: true visible: intrinsicsFilterButton.checked clip: true TableView { id : intrinsicTable visible: intrinsicsFilterButton.checked anchors.fill: parent boundsMovement : Flickable.StopAtBounds palette: root.palette // Provide width for column // Note no size provided for the last column (bool comp) so it uses its automated size columnWidthProvider: function (column) { return intrinsicModel.columnWidths[column] } model: intrinsicModel delegate: IntrinsicDisplayDelegate { attribute: model.display readOnly: m.currentCameraInit ? m.currentCameraInit.locked : false } ScrollBar.horizontal: MScrollBar { id: sb } ScrollBar.vertical : MScrollBar { id: sbv } } TableModel { id : intrinsicModel property bool ready: false // Hardcoded default width per column property var columnWidths: [105, 75, 75, 75, 60, 60, 60, 60, 200, 60, 60, 60] property var columnNames: [ "intrinsicId", "initialFocalLength", "focalLength", "type", "width", "height", "sensorWidth", "sensorHeight", "serialNumber", "principalPoint.x", "principalPoint.y", "locked" ] TableModelColumn { display: function(modelIndex){return parsedIntrinsic[modelIndex.row][intrinsicModel.columnNames[0]]} } TableModelColumn { display: function(modelIndex){return parsedIntrinsic[modelIndex.row][intrinsicModel.columnNames[1]]} } TableModelColumn { display: function(modelIndex){return parsedIntrinsic[modelIndex.row][intrinsicModel.columnNames[2]]} } TableModelColumn { display: function(modelIndex){return parsedIntrinsic[modelIndex.row][intrinsicModel.columnNames[3]]} } TableModelColumn { display: function(modelIndex){return parsedIntrinsic[modelIndex.row][intrinsicModel.columnNames[4]]} } TableModelColumn { display: function(modelIndex){return parsedIntrinsic[modelIndex.row][intrinsicModel.columnNames[5]]} } TableModelColumn { display: function(modelIndex){return parsedIntrinsic[modelIndex.row][intrinsicModel.columnNames[6]]} } TableModelColumn { display: function(modelIndex){return parsedIntrinsic[modelIndex.row][intrinsicModel.columnNames[7]]} } TableModelColumn { display: function(modelIndex){return parsedIntrinsic[modelIndex.row][intrinsicModel.columnNames[8]]} } TableModelColumn { display: function(modelIndex){return parsedIntrinsic[modelIndex.row][intrinsicModel.columnNames[9]]} } TableModelColumn { display: function(modelIndex){return parsedIntrinsic[modelIndex.row][intrinsicModel.columnNames[10]]} } TableModelColumn { display: function(modelIndex){return parsedIntrinsic[modelIndex.row][intrinsicModel.columnNames[11]]} } //https://doc.qt.io/qt-5/qml-qt-labs-qmlmodels-tablemodel.html#appendRow-method Component.onCompleted: { ready = true // Triggers "populate_model" in case the intrinsics have been filled while the model was // being instantiated root.populate_model() } } //CODE FOR HEADERS //UNCOMMENT WHEN COMPATIBLE WITH THE RIGHT QT VERSION // HorizontalHeaderView { // id: horizontalHeader // syncView: tableView // anchors.left: tableView.left // } } RowLayout { Layout.fillHeight: false visible: root.cameraInits ? root.cameraInits.count > 1 : false Layout.alignment: Qt.AlignHCenter spacing: 2 ToolButton { text: MaterialIcons.navigate_before property string previousGroupName: { if (root.cameraInits && root.cameraInitIndex - 1 >= 0) { return root.cameraInits.at(root.cameraInitIndex - 1).label } return "" } font.family: MaterialIcons.fontFamily ToolTip.text: "Previous Group (Alt+Left): " + previousGroupName ToolTip.visible: hovered enabled: nodesCB.currentIndex > 0 onClicked: nodesCB.decrementCurrentIndex() } Label { id: groupLabel text: "Group " } ComboBox { id: nodesCB model: { // Create an array from 1 to cameraInits.count for the // display of group indices (real indices still are from // 0 to cameraInits.count - 1) var l = []; if (root.cameraInits) { for (var i = 1; i <= root.cameraInits.count; i++) { l.push(i); } } return l; } implicitWidth: 40 currentIndex: root.cameraInitIndex onActivated: root.changeCurrentIndex(currentIndex) } Label { text: "/ " + (root.cameraInits ? root.cameraInits.count : "Unknown") } ToolButton { text: MaterialIcons.navigate_next property string nextGroupName: { if (root.cameraInits && root.cameraInitIndex + 1 < root.cameraInits.count) { var group = root.cameraInits.at(root.cameraInitIndex + 1) if (group) return root.cameraInits.at(root.cameraInitIndex + 1).label } return "" } font.family: MaterialIcons.fontFamily ToolTip.text: "Next Group (Alt+Right): " + nextGroupName ToolTip.visible: hovered enabled: root.cameraInits ? nodesCB.currentIndex < root.cameraInits.count - 1 : false onClicked: nodesCB.incrementCurrentIndex() } } RowLayout { Layout.fillHeight: false Layout.alignment: Qt.AlignHCenter visible: root.cameraInits ? root.cameraInits.count > 1 : false Label { id: groupName text: root.cameraInit ? "" + root.cameraInit.label + "" + (root.cameraInit.label !== root.cameraInit.defaultLabel ? " (" + root.cameraInit.defaultLabel + ")" : "") : "" font.pointSize: 8 } } } footerContent: RowLayout { // Images count id: footer function resetButtons() { inputImagesFilterButton.checked = false estimatedCamerasFilterButton.checked = false nonEstimatedCamerasFilterButton.checked = false } MaterialToolLabelButton { id : inputImagesFilterButton Layout.minimumWidth: childrenRect.width ToolTip.text: (layoutLoader.item && layoutLoader.item.model ? layoutLoader.item.model.count : 0) + " Input Images" iconText: MaterialIcons.image label: (m.viewpoints ? m.viewpoints.count : 0) padding: 3 checkable: true checked: true onCheckedChanged: { if (checked) { sortedModel.reconstructionFilter = undefined; estimatedCamerasFilterButton.checked = false; nonEstimatedCamerasFilterButton.checked = false; intrinsicsFilterButton.checked = false; } else { if (estimatedCamerasFilterButton.checked === false && nonEstimatedCamerasFilterButton.checked === false && intrinsicsFilterButton.checked === false) inputImagesFilterButton.checked = true } } } // Estimated cameras count MaterialToolLabelButton { id : estimatedCamerasFilterButton Layout.minimumWidth: childrenRect.width ToolTip.text: label + " Estimated Cameras" iconText: MaterialIcons.videocam label: _currentScene && _currentScene.nbCameras ? _currentScene.nbCameras.toString() : "-" padding: 3 enabled: _currentScene ? _currentScene.cameraInit && _currentScene.nbCameras : false checkable: true checked: false onCheckedChanged: { if (checked) { sortedModel.reconstructionFilter = true inputImagesFilterButton.checked = false nonEstimatedCamerasFilterButton.checked = false intrinsicsFilterButton.checked = false } else { if (inputImagesFilterButton.checked === false && nonEstimatedCamerasFilterButton.checked === false && intrinsicsFilterButton.checked === false) inputImagesFilterButton.checked = true } } onEnabledChanged: { if (!enabled) { if (checked) inputImagesFilterButton.checked = true checked = false } } } // Non estimated cameras count MaterialToolLabelButton { id : nonEstimatedCamerasFilterButton Layout.minimumWidth: childrenRect.width ToolTip.text: label + " Non Estimated Cameras" iconText: MaterialIcons.videocam_off label: _currentScene && _currentScene.nbCameras ? ((m.viewpoints ? m.viewpoints.count : 0) - _currentScene.nbCameras.toString()).toString() : "-" padding: 3 enabled: _currentScene ? _currentScene.cameraInit && _currentScene.nbCameras : false checkable: true checked: false onCheckedChanged: { if (checked) { sortedModel.reconstructionFilter = false inputImagesFilterButton.checked = false estimatedCamerasFilterButton.checked = false intrinsicsFilterButton.checked = false } else { if (inputImagesFilterButton.checked === false && estimatedCamerasFilterButton.checked === false && intrinsicsFilterButton.checked === false) inputImagesFilterButton.checked = true } } onEnabledChanged: { if (!enabled) { if (checked) inputImagesFilterButton.checked = true checked = false } } } MaterialToolLabelButton { id : intrinsicsFilterButton Layout.minimumWidth: childrenRect.width ToolTip.text: label + " Number of intrinsics" iconText: MaterialIcons.camera label: _currentScene ? (m.intrinsics ? m.intrinsics.count : 0) : "0" padding: 3 enabled: m.intrinsics ? m.intrinsics.count > 0 : false checkable: true checked: false onCheckedChanged: { if (checked) { inputImagesFilterButton.checked = false estimatedCamerasFilterButton.checked = false nonEstimatedCamerasFilterButton.checked = false } else { if (inputImagesFilterButton.checked === false && estimatedCamerasFilterButton.checked === false && nonEstimatedCamerasFilterButton.checked === false) inputImagesFilterButton.checked = true } } onEnabledChanged: { if (!enabled) { if (checked) inputImagesFilterButton.checked = true checked = false } } } Item { Layout.fillHeight: true Layout.fillWidth: true } MaterialToolLabelButton { id: displayHDR Layout.minimumWidth: childrenRect.width property var activeNode: _currentScene ? _currentScene.activeNodes.get("LdrToHdrMerge").node : null ToolTip.text: "Visualize HDR images: " + (activeNode ? activeNode.label : "No Node") iconText: MaterialIcons.filter label: activeNode ? activeNode.attribute("nbBrackets").value : "" visible: activeNode enabled: activeNode && activeNode.isComputed && (m.viewpoints ? m.viewpoints.count > 0 : false) property string nodeID: activeNode ? (activeNode.label + activeNode.isComputed) : "" onNodeIDChanged: { if (checked) { open() } } onEnabledChanged: { // Reset the toggle to avoid getting stuck with the HDR node checked but disabled if (checked) { checked = false close() } } checkable: true checked: false onClicked: { if (checked) { open() } else { close() } } function open() { if (imageProcessing.checked) imageProcessing.checked = false _currentScene.setupTempCameraInit(activeNode, "outSfMData") } function close() { _currentScene.clearTempCameraInit() } } MaterialToolButton { id: imageProcessing Layout.minimumWidth: childrenRect.width property var activeNode: _currentScene ? _currentScene.activeNodes.get("ImageProcessing").node : null font.pointSize: 15 padding: 0 ToolTip.text: "Preprocessed Images: " + (activeNode ? activeNode.label : "No Node") text: MaterialIcons.wallpaper visible: activeNode && activeNode.attribute("outSfMData").value enabled: activeNode && activeNode.isComputed property string nodeID: activeNode ? (activeNode.label + activeNode.isComputed) : "" onNodeIDChanged: { if (checked) { open() } } onEnabledChanged: { // Reset the toggle to avoid getting stuck with the HDR node checked but disabled if (checked) { checked = false close() } } checkable: true checked: false onClicked: { if (checked) { open() } else { close() } } function open() { if (displayHDR.checked) displayHDR.checked = false _currentScene.setupTempCameraInit(activeNode, "outSfMData") } function close() { _currentScene.clearTempCameraInit() } } Item { Layout.fillHeight: true width: 1 } // Thumbnail size icon and slider MaterialToolButton { Layout.minimumWidth: childrenRect.width text: MaterialIcons.photo_size_select_large ToolTip.text: "Thumbnails Scale" padding: 0 anchors.margins: 0 font.pointSize: 11 onClicked: { thumbnailSizeSlider.value = defaultCellSize } } Slider { id: thumbnailSizeSlider from: 70 value: defaultCellSize to: 250 implicitWidth: 70 } } MessageDialog { id: errorDialog icon.text: MaterialIcons.error icon.color: "#F44336" title: "Different File Types" text: "Do not mix .mg files and other types of files." standardButtons: Dialog.Ok parent: Overlay.overlay onAccepted: close() } } ================================================ FILE: meshroom/ui/qml/ImageGallery/ImageGridView.qml ================================================ import QtQuick import QtQuick.Controls import QtQuick.Layouts import QtQml.Models import Qt.labs.qmlmodels import Controls 1.0 import MaterialIcons 2.2 import Utils 1.0 GridView { id: root // Exposed properties from ImageGallery property var m: null property var gallery: null property var searchBar: null property var thumbnailSizeSlider: null property var intrinsicsFilterButton: null property var tempCameraInit: null property var errorDialog: null property var sortedModel: null // Signals signal removeImageRequest(var attribute) signal allViewpointsCleared() ScrollBar.vertical: MScrollBar { active: true } focus: true clip: true cellWidth: thumbnailSizeSlider ? thumbnailSizeSlider.value : 160 cellHeight: cellWidth highlightFollowsCurrentItem: true keyNavigationEnabled: true highlightMoveDuration: 0 // Update grid current item when selected view changes Connections { target: _currentScene function onSelectedViewIdChanged() { if (_currentScene.selectedViewId > -1) { root.updateCurrentIndexFromSelectionViewId() } } } function makeCurrentItemVisible() { root.positionViewAtIndex(root.currentIndex, GridView.Visible) } function updateCurrentIndexFromSelectionViewId() { if (!sortedModel) return var idx = sortedModel.find(_currentScene.selectedViewId, "viewId") if (idx >= 0 && root.currentIndex !== idx) { root.currentIndex = idx } } onCurrentItemChanged: { if (root.currentItem) { if (tempCameraInit !== null && root.currentIndex == 0) _currentScene.selectedViewId = -1 _currentScene.selectedViewId = root.currentItem.viewpoint.get("viewId").value } } // Update grid item when corresponding thumbnail is computed Connections { target: ThumbnailCache function onThumbnailCreated(imgSource, callerID) { let item = root.itemAtIndex(callerID); if (item && item.source === imgSource) { item.updateThumbnail() return } for (let idx = 0; idx < root.count; idx++) { item = root.itemAtIndex(idx) if (item && item.source === imgSource) { item.updateThumbnail() } } } } model: sortedModel // Keyboard shortcut to change current image group Keys.priority: Keys.BeforeItem Keys.onPressed: function(event) { if (event.modifiers & Qt.AltModifier) { if (event.key === Qt.Key_Right && gallery && gallery.cameraInits) { _currentScene.cameraInitIndex = Math.min(gallery.cameraInits.count - 1, gallery.cameraInitIndex + 1) event.accepted = true } else if (event.key === Qt.Key_Left) { _currentScene.cameraInitIndex = Math.max(0, gallery.cameraInitIndex - 1) event.accepted = true } } else { if (event.key === Qt.Key_Right) { root.moveCurrentIndexRight() event.accepted = true } else if (event.key === Qt.Key_Left) { root.moveCurrentIndexLeft() event.accepted = true } else if (event.key === Qt.Key_Up) { root.moveCurrentIndexUp() event.accepted = true } else if (event.key === Qt.Key_Down) { root.moveCurrentIndexDown() event.accepted = true } else if (event.key === Qt.Key_Tab) { if (searchBar) searchBar.forceActiveFocus() event.accepted = true } } } // Explanatory placeholder when no image has been added yet Column { id: dropImagePlaceholder anchors.centerIn: parent visible: (m && m.viewpoints ? m.viewpoints.count === 0 : true) && (!intrinsicsFilterButton || !intrinsicsFilterButton.checked) spacing: 4 Label { anchors.horizontalCenter: parent.horizontalCenter text: MaterialIcons.photo_library font.pointSize: 24 font.family: MaterialIcons.fontFamily } Label { text: "Drop Image Files / Folders" } } // Placeholder when the filtered images list is empty Column { id: noImageImagePlaceholder anchors.centerIn: parent visible: (m && m.viewpoints ? m.viewpoints.count !== 0 : false) && !dropImagePlaceholder.visible && root.count === 0 && (!intrinsicsFilterButton || !intrinsicsFilterButton.checked) spacing: 4 Label { anchors.horizontalCenter: parent.horizontalCenter text: MaterialIcons.filter_none font.pointSize: 24 font.family: MaterialIcons.fontFamily } Label { text: "No images in this filtered view" } } DropArea { id: dropArea anchors.fill: parent enabled: m && !m.readOnly && (!intrinsicsFilterButton || !intrinsicsFilterButton.checked) keys: ["text/uri-list"] property int nbDraggedFiles: 0 property var filesByType: ({}) property int nbMeshroomScenes: 0 onEntered: function(drag) { nbDraggedFiles = drag.urls.length filesByType = _currentScene.getFilesByTypeFromDrop(drag.urls) nbMeshroomScenes = filesByType["meshroomScenes"].length } onDropped: function(drop) { if (nbMeshroomScenes === nbDraggedFiles || nbMeshroomScenes === 0) { if (gallery) gallery.filesDropped(filesByType) } else { if (errorDialog) errorDialog.open() } } // Background opacifier Rectangle { visible: dropArea.containsDrag anchors.fill: parent color: gallery ? gallery.palette.window : palette.window opacity: 0.8 } Label { id: addArea anchors.fill: parent visible: dropArea.containsDrag horizontalAlignment: Text.AlignHCenter verticalAlignment: Text.AlignVCenter text: { if (dropArea.nbMeshroomScenes != dropArea.nbDraggedFiles && dropArea.nbMeshroomScenes != 0) { return "Cannot Add Projects And Images Together" } if (dropArea.nbMeshroomScenes == 1 && dropArea.nbMeshroomScenes == dropArea.nbDraggedFiles) { return "Load Project" } else if (dropArea.nbMeshroomScenes == dropArea.nbDraggedFiles) { return "Only One Project" } else { return "Add Images" } } font.bold: true background: Rectangle { color: dropArea.containsDrag ? parent.palette.highlight : parent.palette.window opacity: 0.8 border.color: parent.palette.highlight } } } MouseArea { anchors.fill: parent onPressed: function(mouse) { if (mouse.button == Qt.LeftButton) root.forceActiveFocus() mouse.accepted = false } } } ================================================ FILE: meshroom/ui/qml/ImageGallery/ImageListView.qml ================================================ import QtQuick import QtQuick.Controls import QtQuick.Layouts import QtQml.Models import Qt.labs.qmlmodels import Controls 1.0 import MaterialIcons 2.2 import Utils 1.0 ListView { id: root // Exposed properties from ImageGallery property var m: null property var gallery: null property var searchBar: null property var thumbnailSizeSlider: null property var intrinsicsFilterButton: null property var tempCameraInit: null property var errorDialog: null property var sortedModel: null property real cellHeight: thumbnailSizeSlider ? thumbnailSizeSlider.value / 2 : 80 // Signals signal removeImageRequest(var attribute) signal allViewpointsCleared() ScrollBar.vertical: MScrollBar { active: true } focus: true clip: true spacing: 2 highlightFollowsCurrentItem: true keyNavigationEnabled: true highlightMoveDuration: 0 // Update list current item when selected view changes Connections { target: _currentScene function onSelectedViewIdChanged() { if (_currentScene.selectedViewId > -1) { root.updateCurrentIndexFromSelectionViewId() } } } function makeCurrentItemVisible() { root.positionViewAtIndex(root.currentIndex, ListView.Visible) } function updateCurrentIndexFromSelectionViewId() { if (!sortedModel) return var idx = sortedModel.find(_currentScene.selectedViewId, "viewId") if (idx >= 0 && root.currentIndex !== idx) { root.currentIndex = idx } } onCurrentItemChanged: { if (root.currentItem) { if (tempCameraInit !== null && root.currentIndex == 0) _currentScene.selectedViewId = -1 _currentScene.selectedViewId = root.currentItem.viewpoint.get("viewId").value } } // Update list item when corresponding thumbnail is computed Connections { target: ThumbnailCache function onThumbnailCreated(imgSource, callerID) { let item = root.itemAtIndex(callerID); if (item && item.source === imgSource) { item.updateThumbnail() return } for (let idx = 0; idx < root.count; idx++) { item = root.itemAtIndex(idx) if (item && item.source === imgSource) { item.updateThumbnail() } } } } model: sortedModel // Keyboard shortcut to change current image group Keys.priority: Keys.BeforeItem Keys.onPressed: function(event) { if (event.modifiers & Qt.AltModifier) { if (event.key === Qt.Key_Right && gallery && gallery.cameraInits) { _currentScene.cameraInitIndex = Math.min(gallery.cameraInits.count - 1, gallery.cameraInitIndex + 1) event.accepted = true } else if (event.key === Qt.Key_Left) { _currentScene.cameraInitIndex = Math.max(0, gallery.cameraInitIndex - 1) event.accepted = true } } else { if (event.key === Qt.Key_Down) { root.incrementCurrentIndex() event.accepted = true } else if (event.key === Qt.Key_Up) { root.decrementCurrentIndex() event.accepted = true } else if (event.key === Qt.Key_Tab) { if (searchBar) searchBar.forceActiveFocus() event.accepted = true } } } // Explanatory placeholder when no image has been added yet Column { id: dropImagePlaceholder anchors.centerIn: parent visible: (m && m.viewpoints ? m.viewpoints.count === 0 : true) && (!intrinsicsFilterButton || !intrinsicsFilterButton.checked) spacing: 4 Label { anchors.horizontalCenter: parent.horizontalCenter text: MaterialIcons.photo_library font.pointSize: 24 font.family: MaterialIcons.fontFamily } Label { text: "Drop Image Files / Folders" } } // Placeholder when the filtered images list is empty Column { id: noImageImagePlaceholder anchors.centerIn: parent visible: (m && m.viewpoints ? m.viewpoints.count !== 0 : false) && !dropImagePlaceholder.visible && root.count === 0 && (!intrinsicsFilterButton || !intrinsicsFilterButton.checked) spacing: 4 Label { anchors.horizontalCenter: parent.horizontalCenter text: MaterialIcons.filter_none font.pointSize: 24 font.family: MaterialIcons.fontFamily } Label { text: "No images in this filtered view" } } DropArea { id: dropArea anchors.fill: parent enabled: m && !m.readOnly && (!intrinsicsFilterButton || !intrinsicsFilterButton.checked) keys: ["text/uri-list"] property int nbDraggedFiles: 0 property var filesByType: ({}) property int nbMeshroomScenes: 0 onEntered: function(drag) { nbDraggedFiles = drag.urls.length filesByType = _currentScene.getFilesByTypeFromDrop(drag.urls) nbMeshroomScenes = filesByType["meshroomScenes"].length } onDropped: function(drop) { if (nbMeshroomScenes == nbDraggedFiles || nbMeshroomScenes == 0) { if (gallery) gallery.filesDropped(filesByType) } else { if (errorDialog) errorDialog.open() } } // Background opacifier Rectangle { visible: dropArea.containsDrag anchors.fill: parent color: gallery ? gallery.palette.window : palette.window opacity: 0.8 } Label { id: addArea anchors.fill: parent visible: dropArea.containsDrag horizontalAlignment: Text.AlignHCenter verticalAlignment: Text.AlignVCenter text: { if (dropArea.nbMeshroomScenes != dropArea.nbDraggedFiles && dropArea.nbMeshroomScenes != 0) { return "Cannot Add Projects And Images Together" } if (dropArea.nbMeshroomScenes == 1 && dropArea.nbMeshroomScenes == dropArea.nbDraggedFiles) { return "Load Project" } else if (dropArea.nbMeshroomScenes == dropArea.nbDraggedFiles) { return "Only One Project" } else { return "Add Images" } } font.bold: true background: Rectangle { color: dropArea.containsDrag ? parent.palette.highlight : parent.palette.window opacity: 0.8 border.color: parent.palette.highlight } } } MouseArea { anchors.fill: parent onPressed: function(mouse) { if (mouse.button == Qt.LeftButton) root.forceActiveFocus() mouse.accepted = false } } } ================================================ FILE: meshroom/ui/qml/ImageGallery/IntrinsicDisplayDelegate.qml ================================================ import QtQuick import QtQuick.Layouts import QtQuick.Controls RowLayout { id: root Layout.fillWidth: true property variant attribute: null property int rowIndex: model.row property int columnIndex: model.column property bool readOnly: false property string toolTipText: { if (!attribute || attribute.label === undefined) return "" return attribute.label } Pane { Layout.minimumWidth: loaderComponent.width Layout.minimumHeight: loaderComponent.height Layout.fillWidth: true padding: 0 hoverEnabled: true // Tooltip to replace headers for now (header incompatible atm) ToolTip.delay: 10 ToolTip.timeout: 5000 ToolTip.visible: hovered ToolTip.text: toolTipText Rectangle { width: parent.width height: loaderComponent.height color: rowIndex % 2 ? palette.window : Qt.darker(palette.window, 1.1) border.width: 2 border.color: Qt.darker(palette.window, 1.2) clip: true Loader { id: loaderComponent active: !!attribute // convert to bool with "!!" sourceComponent: { if (!attribute) return undefined switch (attribute.type) { case "ChoiceParam": return choiceComponent case "IntParam": return intComponent case "FloatParam": return floatComponent case "BoolParam": return boolComponent case "StringParam": return textFieldComponent case "File": return textFieldComponent default: return undefined } } } } } Component { id: textFieldComponent TextInput { text: attribute.value width: intrinsicModel.columnWidths[columnIndex] horizontalAlignment: TextInput.AlignRight readOnly: root.readOnly color: palette.text padding: 12 selectByMouse: true selectionColor: palette.text selectedTextColor: Qt.darker(palette.window, 1.1) onEditingFinished: _currentScene.setAttribute(attribute, text) onAccepted: { _currentScene.setAttribute(attribute, text) } Component.onDestruction: { if (activeFocus) _currentScene.setAttribute(attribute, text) } } } Component { id: intComponent TextInput { text: model.display.value width: intrinsicModel.columnWidths[columnIndex] horizontalAlignment: TextInput.AlignRight color: palette.text readOnly: root.readOnly padding: 12 selectByMouse: true selectionColor: palette.text selectedTextColor: Qt.darker(palette.window, 1.1) IntValidator { id: intValidator } validator: intValidator onEditingFinished: _currentScene.setAttribute(attribute, Number(text)) onAccepted: { _currentScene.setAttribute(attribute, Number(text)) } Component.onDestruction: { if (activeFocus) _currentScene.setAttribute(attribute, Number(text)) } } } Component { id: choiceComponent ComboBox { id: combo model: attribute.desc !== undefined ? attribute.desc.values : undefined width: intrinsicModel.columnWidths[columnIndex] enabled: !root.readOnly flat : true topInset: 7 leftInset: 6 rightInset: 6 bottomInset: 7 Component.onCompleted: currentIndex = find(attribute.value) onActivated: _currentScene.setAttribute(attribute, currentText) Connections { target: attribute function onValueChanged() { combo.currentIndex = combo.find(attribute.value) } } } } Component { id: boolComponent CheckBox { checked: attribute ? attribute.value : false padding: 12 enabled: !readOnly onToggled: _currentScene.setAttribute(attribute, !attribute.value) } } Component { id: floatComponent TextInput { readonly property real formattedValue: attribute.value.toFixed(2) property string displayValue: String(formattedValue) text: displayValue width: intrinsicModel.columnWidths[columnIndex] horizontalAlignment: TextInput.AlignRight color: palette.text padding: 12 selectByMouse: true selectionColor: palette.text selectedTextColor: Qt.darker(palette.window, 1.1) readOnly: root.readOnly enabled: !readOnly clip: true autoScroll: activeFocus // Use this function to ensure the left part is visible // while keeping the trick for formatting the text // Timing issues otherwise onActiveFocusChanged: { if (activeFocus) text = String(attribute.value) else text = String(formattedValue) cursorPosition = 0 } DoubleValidator { id: doubleValidator locale: 'C' // Use '.' decimal separator disregarding the system locale } validator: doubleValidator onEditingFinished: _currentScene.setAttribute(attribute, Number(text)) onAccepted: { _currentScene.setAttribute(attribute, Number(text)) } Component.onDestruction: { if (activeFocus) _currentScene.setAttribute(attribute, Number(text)) } } } } ================================================ FILE: meshroom/ui/qml/ImageGallery/IntrinsicsIndicator.qml ================================================ import QtQuick import QtQuick.Controls import MaterialIcons 2.2 import Utils 1.0 /** * Display camera initialization status and the value of metadata * that take part in this process. */ ImageBadge { id: root // Intrinsic GroupAttribute property var intrinsic: null readonly property string intrinsicInitMode: intrinsic ? childAttributeValue(intrinsic, "initializationMode", "none") : "unknown" readonly property string distortionInitMode: intrinsic ? childAttributeValue(intrinsic, "distortionInitializationMode", "none") : "unknown" readonly property string distortionModel: intrinsic ? childAttributeValue(intrinsic, "type", "") : "" property var metadata: ({}) function findMetadata(key) { var keyLower = key.toLowerCase() for (var mKey in metadata) { if (mKey.toLowerCase().endsWith(keyLower)) return metadata[mKey] } return "" } // Access useful metadata readonly property var make: findMetadata("Make") readonly property var model: findMetadata("Model") readonly property var focalLength: findMetadata("FocalLength") readonly property var focalLength35: findMetadata("FocalLengthIn35mmFilm") readonly property var bodySerialNumber: findMetadata("BodySerialNumber") readonly property var lensSerialNumber: findMetadata("LensSerialNumber") readonly property var sensorWidth: metadata["AliceVision:SensorWidth"] readonly property var sensorWidthEstimation: metadata["AliceVision:SensorWidthEstimation"] property string statusText: "" property string detailsText: "" property string helperText: "" text: MaterialIcons.camera function childAttributeValue(attribute, childName, defaultValue) { var attr = attribute.childAttribute(childName); return attr ? attr.value : defaultValue; } function metaStr(value) { return value || "undefined" } ToolTip.text: "Camera Intrinsics: " + statusText + "
" + (detailsText ? detailsText + "
" : "") + (helperText ? helperText + "
" : "") + "
" + "Distortion: " + distortionInitMode + "
" + (distortionModel ? 'Distortion Model: ' + distortionModel + "
" : "") + "
" + "[Metadata]
" + " - Make: " + metaStr(make) + "
" + " - Model: " + metaStr(model) + "
" + " - FocalLength: " + metaStr(focalLength) + "
" + ((focalLength && sensorWidth) ? "" : " - FocalLengthIn35mmFilm: " + metaStr(focalLength35) + "
") + " - SensorWidth: " + metaStr(sensorWidth) + (sensorWidthEstimation ? " (estimation: "+ sensorWidthEstimation + ")" : "") + ((bodySerialNumber || lensSerialNumber) ? "" : "

Warning: SerialNumber metadata is missing.
Images from different devices might incorrectly share the same camera internal settings.") state: intrinsicInitMode states: [ State { name: "calibrated" PropertyChanges { target: root color: Colors.green statusText: "Calibrated" detailsText: "Focal Length has been initialized externally." } }, State { name: "estimated" PropertyChanges { target: root statusText: sensorWidth ? "Estimated" : "Approximated" color: sensorWidth ? Colors.green : Colors.yellow detailsText: "Focal Length was estimated from Metadata" + (sensorWidth ? " and Sensor Database." : " only.") helperText: !sensorWidth ? "Add your Camera Model to the Sensor Database for more accurate results." : "" } }, State { name: "unknown" PropertyChanges { target: root color: focalLength ? Colors.orange : Colors.red statusText: "Unknown" detailsText: "Focal Length could not be determined from metadata.
" + "The default Field of View value was used as a fallback, which may lead to inaccurate result or failure." helperText: "Check for missing Image metadata" + (make && model && !sensorWidth ? " and/or add your Camera Model to the Sensor Database." : ".") } }, State { // Fallback status when initialization mode is unset name: "none" PropertyChanges { target: root visible: false } } ] } ================================================ FILE: meshroom/ui/qml/ImageGallery/SensorDBDialog.qml ================================================ import QtQuick import QtQuick.Controls import QtQuick.Layouts import MaterialIcons 2.2 import Controls 1.0 MessageDialog { id: root property url sensorDatabase property bool readOnly: false signal updateIntrinsicsRequest() icon.text: MaterialIcons.camera icon.font.pointSize: 10 parent: Overlay.overlay canCopy: false title: "Sensor Database" text: "Add missing Camera Models to the Sensor Database to improve your results." detailedText: "If a warning is displayed on your images, adding your Camera Model to the Sensor Database can help fix it and improve your reconstruction results." helperText: 'To update the Sensor Database (complete guide):
' + ' - Look for the "sensor width" in millimeters of your Camera Model
' + ' - Add a new line in the Database following this pattern: Make;Model;SensorWidthInMM
' + ' - Click on "Update Intrinsics" once the Database has been saved
' + ' - Contribute to the online Database' content: ColumnLayout { RowLayout { Layout.fillWidth: true spacing: 2 Label { text: "Sensor Database:" } TextField { id: sensorDBTextField Layout.fillWidth: true text: Filepath.normpath(sensorDatabase) selectByMouse: true readOnly: true } MaterialToolButton { text: MaterialIcons.assignment ToolTip.text: "Copy Path" onClicked: { sensorDBTextField.selectAll(); sensorDBTextField.copy(); ToolTip.text = "Path has been copied!" } onHoveredChanged: if(!hovered) ToolTip.text = "Copy Path" } MaterialToolButton { text: MaterialIcons.open_in_new ToolTip.text: "Open in External Editor" onClicked: Qt.openUrlExternally(sensorDatabase) } } Button { id: rebuildIntrinsics text: "Update Intrinsics" enabled: !readOnly onClicked: updateIntrinsicsRequest() Layout.alignment: Qt.AlignCenter } } standardButtons: Dialog.Close onAccepted: close() } ================================================ FILE: meshroom/ui/qml/ImageGallery/qmldir ================================================ module ImageGallery ImageGallery 1.0 ImageGallery.qml ImageDelegate 1.0 ImageDelegate.qml ImageGridView 1.0 ImageGridView.qml ImageListView 1.0 ImageListView.qml ImageIntrinsicDelegate 1.0 ImageIntrinsicDelegate.qml ImageIntrinsicViewer 1.0 ImageIntrinsicViewer.qml IntrinsicDisplayDelegate 1.0 IntrinsicDisplayDelegate.qml ================================================ FILE: meshroom/ui/qml/MaterialIcons/MLabel.qml ================================================ import QtQuick import QtQuick.Controls /** * MLabel is a standard Label. * If ToolTip.text is set, it shows up a tooltip when hovered. */ Label { padding: 4 MouseArea { id: mouseArea anchors.fill: parent hoverEnabled: true acceptedButtons: Qt.NoButton } ToolTip.visible: mouseArea.containsMouse ToolTip.delay: 500 background: Rectangle { anchors.fill: parent color: mouseArea.containsMouse ? Qt.darker(parent.palette.base, 0.6) : "transparent" } } ================================================ FILE: meshroom/ui/qml/MaterialIcons/MaterialIcons.qml ================================================ pragma Singleton import QtQuick QtObject { property FontLoader fl: FontLoader { source: "./MaterialIcons-Regular.ttf" } readonly property string fontFamily: fl.name readonly property string _10k: "\ue951" readonly property string _10mp: "\ue952" readonly property string _11mp: "\ue953" readonly property string _123: "\ueb8d" readonly property string _12mp: "\ue954" readonly property string _13mp: "\ue955" readonly property string _14mp: "\ue956" readonly property string _15mp: "\ue957" readonly property string _16mp: "\ue958" readonly property string _17mp: "\ue959" readonly property string _18_up_rating: "\uf8fd" readonly property string _18mp: "\ue95a" readonly property string _19mp: "\ue95b" readonly property string _1k: "\ue95c" readonly property string _1k_plus: "\ue95d" readonly property string _1x_mobiledata: "\uefcd" readonly property string _20mp: "\ue95e" readonly property string _21mp: "\ue95f" readonly property string _22mp: "\ue960" readonly property string _23mp: "\ue961" readonly property string _24mp: "\ue962" readonly property string _2k: "\ue963" readonly property string _2k_plus: "\ue964" readonly property string _2mp: "\ue965" readonly property string _30fps: "\uefce" readonly property string _30fps_select: "\uefcf" readonly property string _360: "\ue577" readonly property string _3d_rotation: "\ue84d" readonly property string _3g_mobiledata: "\uefd0" readonly property string _3k: "\ue966" readonly property string _3k_plus: "\ue967" readonly property string _3mp: "\ue968" readonly property string _3p: "\uefd1" readonly property string _4g_mobiledata: "\uefd2" readonly property string _4g_plus_mobiledata: "\uefd3" readonly property string _4k: "\ue072" readonly property string _4k_plus: "\ue969" readonly property string _4mp: "\ue96a" readonly property string _5g: "\uef38" readonly property string _5k: "\ue96b" readonly property string _5k_plus: "\ue96c" readonly property string _5mp: "\ue96d" readonly property string _60fps: "\uefd4" readonly property string _60fps_select: "\uefd5" readonly property string _6_ft_apart: "\uf21e" readonly property string _6k: "\ue96e" readonly property string _6k_plus: "\ue96f" readonly property string _6mp: "\ue970" readonly property string _7k: "\ue971" readonly property string _7k_plus: "\ue972" readonly property string _7mp: "\ue973" readonly property string _8k: "\ue974" readonly property string _8k_plus: "\ue975" readonly property string _8mp: "\ue976" readonly property string _9k: "\ue977" readonly property string _9k_plus: "\ue978" readonly property string _9mp: "\ue979" readonly property string abc: "\ueb94" readonly property string ac_unit: "\ueb3b" readonly property string access_alarm: "\ue190" readonly property string access_alarms: "\ue191" readonly property string access_time: "\ue192" readonly property string access_time_filled: "\uefd6" readonly property string accessibility: "\ue84e" readonly property string accessibility_new: "\ue92c" readonly property string accessible: "\ue914" readonly property string accessible_forward: "\ue934" readonly property string account_balance: "\ue84f" readonly property string account_balance_wallet: "\ue850" readonly property string account_box: "\ue851" readonly property string account_circle: "\ue853" readonly property string account_tree: "\ue97a" readonly property string ad_units: "\uef39" readonly property string adb: "\ue60e" readonly property string add: "\ue145" readonly property string add_a_photo: "\ue439" readonly property string add_alarm: "\ue193" readonly property string add_alert: "\ue003" readonly property string add_box: "\ue146" readonly property string add_business: "\ue729" readonly property string add_call: "\ue0e8" readonly property string add_card: "\ueb86" readonly property string add_chart: "\ue97b" readonly property string add_circle: "\ue147" readonly property string add_circle_outline: "\ue148" readonly property string add_comment: "\ue266" readonly property string add_home: "\uf8eb" readonly property string add_home_work: "\uf8ed" readonly property string add_ic_call: "\ue97c" readonly property string add_link: "\ue178" readonly property string add_location: "\ue567" readonly property string add_location_alt: "\uef3a" readonly property string add_moderator: "\ue97d" readonly property string add_photo_alternate: "\ue43e" readonly property string add_reaction: "\ue1d3" readonly property string add_road: "\uef3b" readonly property string add_shopping_cart: "\ue854" readonly property string add_task: "\uf23a" readonly property string add_to_drive: "\ue65c" readonly property string add_to_home_screen: "\ue1fe" readonly property string add_to_photos: "\ue39d" readonly property string add_to_queue: "\ue05c" readonly property string addchart: "\uef3c" readonly property string adf_scanner: "\ueada" readonly property string adjust: "\ue39e" readonly property string admin_panel_settings: "\uef3d" readonly property string adobe: "\uea96" readonly property string ads_click: "\ue762" readonly property string agriculture: "\uea79" readonly property string air: "\uefd8" readonly property string airline_seat_flat: "\ue630" readonly property string airline_seat_flat_angled: "\ue631" readonly property string airline_seat_individual_suite: "\ue632" readonly property string airline_seat_legroom_extra: "\ue633" readonly property string airline_seat_legroom_normal: "\ue634" readonly property string airline_seat_legroom_reduced: "\ue635" readonly property string airline_seat_recline_extra: "\ue636" readonly property string airline_seat_recline_normal: "\ue637" readonly property string airline_stops: "\ue7d0" readonly property string airlines: "\ue7ca" readonly property string airplane_ticket: "\uefd9" readonly property string airplanemode_active: "\ue195" readonly property string airplanemode_inactive: "\ue194" readonly property string airplanemode_off: "\ue194" readonly property string airplanemode_on: "\ue195" readonly property string airplay: "\ue055" readonly property string airport_shuttle: "\ueb3c" readonly property string alarm: "\ue855" readonly property string alarm_add: "\ue856" readonly property string alarm_off: "\ue857" readonly property string alarm_on: "\ue858" readonly property string album: "\ue019" readonly property string align_horizontal_center: "\ue00f" readonly property string align_horizontal_left: "\ue00d" readonly property string align_horizontal_right: "\ue010" readonly property string align_vertical_bottom: "\ue015" readonly property string align_vertical_center: "\ue011" readonly property string align_vertical_top: "\ue00c" readonly property string all_inbox: "\ue97f" readonly property string all_inclusive: "\ueb3d" readonly property string all_out: "\ue90b" readonly property string alt_route: "\uf184" readonly property string alternate_email: "\ue0e6" readonly property string amp_stories: "\uea13" readonly property string analytics: "\uef3e" readonly property string anchor: "\uf1cd" readonly property string android: "\ue859" readonly property string animation: "\ue71c" readonly property string announcement: "\ue85a" readonly property string aod: "\uefda" readonly property string apartment: "\uea40" readonly property string api: "\uf1b7" readonly property string app_blocking: "\uef3f" readonly property string app_registration: "\uef40" readonly property string app_settings_alt: "\uef41" readonly property string app_shortcut: "\ueae4" readonly property string apple: "\uea80" readonly property string approval: "\ue982" readonly property string apps: "\ue5c3" readonly property string apps_outage: "\ue7cc" readonly property string architecture: "\uea3b" readonly property string archive: "\ue149" readonly property string area_chart: "\ue770" readonly property string arrow_back: "\ue5c4" readonly property string arrow_back_ios: "\ue5e0" readonly property string arrow_back_ios_new: "\ue2ea" readonly property string arrow_circle_down: "\uf181" readonly property string arrow_circle_left: "\ueaa7" readonly property string arrow_circle_right: "\ueaaa" readonly property string arrow_circle_up: "\uf182" readonly property string arrow_downward: "\ue5db" readonly property string arrow_drop_down: "\ue5c5" readonly property string arrow_drop_down_circle: "\ue5c6" readonly property string arrow_drop_up: "\ue5c7" readonly property string arrow_forward: "\ue5c8" readonly property string arrow_forward_ios: "\ue5e1" readonly property string arrow_left: "\ue5de" readonly property string arrow_outward: "\uf8ce" readonly property string arrow_right: "\ue5df" readonly property string arrow_right_alt: "\ue941" readonly property string arrow_upward: "\ue5d8" readonly property string art_track: "\ue060" readonly property string article: "\uef42" readonly property string aspect_ratio: "\ue85b" readonly property string assessment: "\ue85c" readonly property string assignment: "\ue85d" readonly property string assignment_add: "\uf848" readonly property string assignment_ind: "\ue85e" readonly property string assignment_late: "\ue85f" readonly property string assignment_return: "\ue860" readonly property string assignment_returned: "\ue861" readonly property string assignment_turned_in: "\ue862" readonly property string assist_walker: "\uf8d5" readonly property string assistant: "\ue39f" readonly property string assistant_direction: "\ue988" readonly property string assistant_navigation: "\ue989" readonly property string assistant_photo: "\ue3a0" readonly property string assured_workload: "\ueb6f" readonly property string atm: "\ue573" readonly property string attach_email: "\uea5e" readonly property string attach_file: "\ue226" readonly property string attach_money: "\ue227" readonly property string attachment: "\ue2bc" readonly property string attractions: "\uea52" readonly property string attribution: "\uefdb" readonly property string audio_file: "\ueb82" readonly property string audiotrack: "\ue3a1" readonly property string auto_awesome: "\ue65f" readonly property string auto_awesome_mosaic: "\ue660" readonly property string auto_awesome_motion: "\ue661" readonly property string auto_delete: "\uea4c" readonly property string auto_fix_high: "\ue663" readonly property string auto_fix_normal: "\ue664" readonly property string auto_fix_off: "\ue665" readonly property string auto_graph: "\ue4fb" readonly property string auto_mode: "\uec20" readonly property string auto_stories: "\ue666" readonly property string autofps_select: "\uefdc" readonly property string autorenew: "\ue863" readonly property string av_timer: "\ue01b" readonly property string baby_changing_station: "\uf19b" readonly property string back_hand: "\ue764" readonly property string backpack: "\uf19c" readonly property string backspace: "\ue14a" readonly property string backup: "\ue864" readonly property string backup_table: "\uef43" readonly property string badge: "\uea67" readonly property string bakery_dining: "\uea53" readonly property string balance: "\ueaf6" readonly property string balcony: "\ue58f" readonly property string ballot: "\ue172" readonly property string bar_chart: "\ue26b" readonly property string barcode_reader: "\uf85c" readonly property string batch_prediction: "\uf0f5" readonly property string bathroom: "\uefdd" readonly property string bathtub: "\uea41" readonly property string battery_0_bar: "\uebdc" readonly property string battery_1_bar: "\uebd9" readonly property string battery_2_bar: "\uebe0" readonly property string battery_3_bar: "\uebdd" readonly property string battery_4_bar: "\uebe2" readonly property string battery_5_bar: "\uebd4" readonly property string battery_6_bar: "\uebd2" readonly property string battery_alert: "\ue19c" readonly property string battery_charging_full: "\ue1a3" readonly property string battery_full: "\ue1a4" readonly property string battery_saver: "\uefde" readonly property string battery_std: "\ue1a5" readonly property string battery_unknown: "\ue1a6" readonly property string beach_access: "\ueb3e" readonly property string bed: "\uefdf" readonly property string bedroom_baby: "\uefe0" readonly property string bedroom_child: "\uefe1" readonly property string bedroom_parent: "\uefe2" readonly property string bedtime: "\uef44" readonly property string bedtime_off: "\ueb76" readonly property string beenhere: "\ue52d" readonly property string bento: "\uf1f4" readonly property string bike_scooter: "\uef45" readonly property string biotech: "\uea3a" readonly property string blender: "\uefe3" readonly property string blind: "\uf8d6" readonly property string blinds: "\ue286" readonly property string blinds_closed: "\uec1f" readonly property string block: "\ue14b" readonly property string block_flipped: "\uef46" readonly property string bloodtype: "\uefe4" readonly property string bluetooth: "\ue1a7" readonly property string bluetooth_audio: "\ue60f" readonly property string bluetooth_connected: "\ue1a8" readonly property string bluetooth_disabled: "\ue1a9" readonly property string bluetooth_drive: "\uefe5" readonly property string bluetooth_searching: "\ue1aa" readonly property string blur_circular: "\ue3a2" readonly property string blur_linear: "\ue3a3" readonly property string blur_off: "\ue3a4" readonly property string blur_on: "\ue3a5" readonly property string bolt: "\uea0b" readonly property string book: "\ue865" readonly property string book_online: "\uf217" readonly property string bookmark: "\ue866" readonly property string bookmark_add: "\ue598" readonly property string bookmark_added: "\ue599" readonly property string bookmark_border: "\ue867" readonly property string bookmark_outline: "\ue867" readonly property string bookmark_remove: "\ue59a" readonly property string bookmarks: "\ue98b" readonly property string border_all: "\ue228" readonly property string border_bottom: "\ue229" readonly property string border_clear: "\ue22a" readonly property string border_color: "\ue22b" readonly property string border_horizontal: "\ue22c" readonly property string border_inner: "\ue22d" readonly property string border_left: "\ue22e" readonly property string border_outer: "\ue22f" readonly property string border_right: "\ue230" readonly property string border_style: "\ue231" readonly property string border_top: "\ue232" readonly property string border_vertical: "\ue233" readonly property string boy: "\ueb67" readonly property string branding_watermark: "\ue06b" readonly property string breakfast_dining: "\uea54" readonly property string brightness_1: "\ue3a6" readonly property string brightness_2: "\ue3a7" readonly property string brightness_3: "\ue3a8" readonly property string brightness_4: "\ue3a9" readonly property string brightness_5: "\ue3aa" readonly property string brightness_6: "\ue3ab" readonly property string brightness_7: "\ue3ac" readonly property string brightness_auto: "\ue1ab" readonly property string brightness_high: "\ue1ac" readonly property string brightness_low: "\ue1ad" readonly property string brightness_medium: "\ue1ae" readonly property string broadcast_on_home: "\uf8f8" readonly property string broadcast_on_personal: "\uf8f9" readonly property string broken_image: "\ue3ad" readonly property string browse_gallery: "\uebd1" readonly property string browser_not_supported: "\uef47" readonly property string browser_updated: "\ue7cf" readonly property string brunch_dining: "\uea73" readonly property string brush: "\ue3ae" readonly property string bubble_chart: "\ue6dd" readonly property string bug_report: "\ue868" readonly property string build: "\ue869" readonly property string build_circle: "\uef48" readonly property string bungalow: "\ue591" readonly property string burst_mode: "\ue43c" readonly property string bus_alert: "\ue98f" readonly property string business: "\ue0af" readonly property string business_center: "\ueb3f" readonly property string cabin: "\ue589" readonly property string cable: "\uefe6" readonly property string cached: "\ue86a" readonly property string cake: "\ue7e9" readonly property string calculate: "\uea5f" readonly property string calendar_month: "\uebcc" readonly property string calendar_today: "\ue935" readonly property string calendar_view_day: "\ue936" readonly property string calendar_view_month: "\uefe7" readonly property string calendar_view_week: "\uefe8" readonly property string call: "\ue0b0" readonly property string call_end: "\ue0b1" readonly property string call_made: "\ue0b2" readonly property string call_merge: "\ue0b3" readonly property string call_missed: "\ue0b4" readonly property string call_missed_outgoing: "\ue0e4" readonly property string call_received: "\ue0b5" readonly property string call_split: "\ue0b6" readonly property string call_to_action: "\ue06c" readonly property string camera: "\ue3af" readonly property string camera_alt: "\ue3b0" readonly property string camera_enhance: "\ue8fc" readonly property string camera_front: "\ue3b1" readonly property string camera_indoor: "\uefe9" readonly property string camera_outdoor: "\uefea" readonly property string camera_rear: "\ue3b2" readonly property string camera_roll: "\ue3b3" readonly property string cameraswitch: "\uefeb" readonly property string campaign: "\uef49" readonly property string cancel: "\ue5c9" readonly property string cancel_presentation: "\ue0e9" readonly property string cancel_schedule_send: "\uea39" readonly property string candlestick_chart: "\uead4" readonly property string car_crash: "\uebf2" readonly property string car_rental: "\uea55" readonly property string car_repair: "\uea56" readonly property string card_giftcard: "\ue8f6" readonly property string card_membership: "\ue8f7" readonly property string card_travel: "\ue8f8" readonly property string carpenter: "\uf1f8" readonly property string cases: "\ue992" readonly property string casino: "\ueb40" readonly property string cast: "\ue307" readonly property string cast_connected: "\ue308" readonly property string cast_for_education: "\uefec" readonly property string castle: "\ueab1" readonly property string catching_pokemon: "\ue508" readonly property string category: "\ue574" readonly property string celebration: "\uea65" readonly property string cell_tower: "\uebba" readonly property string cell_wifi: "\ue0ec" readonly property string center_focus_strong: "\ue3b4" readonly property string center_focus_weak: "\ue3b5" readonly property string chair: "\uefed" readonly property string chair_alt: "\uefee" readonly property string chalet: "\ue585" readonly property string change_circle: "\ue2e7" readonly property string change_history: "\ue86b" readonly property string charging_station: "\uf19d" readonly property string chat: "\ue0b7" readonly property string chat_bubble: "\ue0ca" readonly property string chat_bubble_outline: "\ue0cb" readonly property string check: "\ue5ca" readonly property string check_box: "\ue834" readonly property string check_box_outline_blank: "\ue835" readonly property string check_circle: "\ue86c" readonly property string check_circle_outline: "\ue92d" readonly property string checklist: "\ue6b1" readonly property string checklist_rtl: "\ue6b3" readonly property string checkroom: "\uf19e" readonly property string chevron_left: "\ue5cb" readonly property string chevron_right: "\ue5cc" readonly property string child_care: "\ueb41" readonly property string child_friendly: "\ueb42" readonly property string chrome_reader_mode: "\ue86d" readonly property string church: "\ueaae" readonly property string circle: "\uef4a" readonly property string circle_notifications: "\ue994" readonly property string class_: "\ue86e" readonly property string clean_hands: "\uf21f" readonly property string cleaning_services: "\uf0ff" readonly property string clear: "\ue14c" readonly property string clear_all: "\ue0b8" readonly property string close: "\ue5cd" readonly property string close_fullscreen: "\uf1cf" readonly property string closed_caption: "\ue01c" readonly property string closed_caption_disabled: "\uf1dc" readonly property string closed_caption_off: "\ue996" readonly property string cloud: "\ue2bd" readonly property string cloud_circle: "\ue2be" readonly property string cloud_done: "\ue2bf" readonly property string cloud_download: "\ue2c0" readonly property string cloud_off: "\ue2c1" readonly property string cloud_queue: "\ue2c2" readonly property string cloud_sync: "\ueb5a" readonly property string cloud_upload: "\ue2c3" readonly property string cloudy_snowing: "\ue810" readonly property string co2: "\ue7b0" readonly property string co_present: "\ueaf0" readonly property string code: "\ue86f" readonly property string code_off: "\ue4f3" readonly property string coffee: "\uefef" readonly property string coffee_maker: "\ueff0" readonly property string collections: "\ue3b6" readonly property string collections_bookmark: "\ue431" readonly property string color_lens: "\ue3b7" readonly property string colorize: "\ue3b8" readonly property string comment: "\ue0b9" readonly property string comment_bank: "\uea4e" readonly property string comments_disabled: "\ue7a2" readonly property string commit: "\ueaf5" readonly property string commute: "\ue940" readonly property string compare: "\ue3b9" readonly property string compare_arrows: "\ue915" readonly property string compass_calibration: "\ue57c" readonly property string compost: "\ue761" readonly property string compress: "\ue94d" readonly property string computer: "\ue30a" readonly property string confirmation_num: "\ue638" readonly property string confirmation_number: "\ue638" readonly property string connect_without_contact: "\uf223" readonly property string connected_tv: "\ue998" readonly property string connecting_airports: "\ue7c9" readonly property string construction: "\uea3c" readonly property string contact_emergency: "\uf8d1" readonly property string contact_mail: "\ue0d0" readonly property string contact_page: "\uf22e" readonly property string contact_phone: "\ue0cf" readonly property string contact_support: "\ue94c" readonly property string contactless: "\uea71" readonly property string contacts: "\ue0ba" readonly property string content_copy: "\ue14d" readonly property string content_cut: "\ue14e" readonly property string content_paste: "\ue14f" readonly property string content_paste_go: "\uea8e" readonly property string content_paste_off: "\ue4f8" readonly property string content_paste_search: "\uea9b" readonly property string contrast: "\ueb37" readonly property string control_camera: "\ue074" readonly property string control_point: "\ue3ba" readonly property string control_point_duplicate: "\ue3bb" readonly property string conveyor_belt: "\uf867" readonly property string cookie: "\ueaac" readonly property string copy_all: "\ue2ec" readonly property string copyright: "\ue90c" readonly property string coronavirus: "\uf221" readonly property string corporate_fare: "\uf1d0" readonly property string cottage: "\ue587" readonly property string countertops: "\uf1f7" readonly property string create: "\ue150" readonly property string create_new_folder: "\ue2cc" readonly property string credit_card: "\ue870" readonly property string credit_card_off: "\ue4f4" readonly property string credit_score: "\ueff1" readonly property string crib: "\ue588" readonly property string crisis_alert: "\uebe9" readonly property string crop: "\ue3be" readonly property string crop_16_9: "\ue3bc" readonly property string crop_3_2: "\ue3bd" readonly property string crop_5_4: "\ue3bf" readonly property string crop_7_5: "\ue3c0" readonly property string crop_din: "\ue3c1" readonly property string crop_free: "\ue3c2" readonly property string crop_landscape: "\ue3c3" readonly property string crop_original: "\ue3c4" readonly property string crop_portrait: "\ue3c5" readonly property string crop_rotate: "\ue437" readonly property string crop_square: "\ue3c6" readonly property string cruelty_free: "\ue799" readonly property string css: "\ueb93" readonly property string currency_bitcoin: "\uebc5" readonly property string currency_exchange: "\ueb70" readonly property string currency_franc: "\ueafa" readonly property string currency_lira: "\ueaef" readonly property string currency_pound: "\ueaf1" readonly property string currency_ruble: "\ueaec" readonly property string currency_rupee: "\ueaf7" readonly property string currency_yen: "\ueafb" readonly property string currency_yuan: "\ueaf9" readonly property string curtains: "\uec1e" readonly property string curtains_closed: "\uec1d" readonly property string cyclone: "\uebd5" readonly property string dangerous: "\ue99a" readonly property string dark_mode: "\ue51c" readonly property string dashboard: "\ue871" readonly property string dashboard_customize: "\ue99b" readonly property string data_array: "\uead1" readonly property string data_exploration: "\ue76f" readonly property string data_object: "\uead3" readonly property string data_saver_off: "\ueff2" readonly property string data_saver_on: "\ueff3" readonly property string data_thresholding: "\ueb9f" readonly property string data_usage: "\ue1af" readonly property string dataset: "\uf8ee" readonly property string dataset_linked: "\uf8ef" readonly property string date_range: "\ue916" readonly property string deblur: "\ueb77" readonly property string deck: "\uea42" readonly property string dehaze: "\ue3c7" readonly property string delete_: "\ue872" readonly property string delete_forever: "\ue92b" readonly property string delete_outline: "\ue92e" readonly property string delete_sweep: "\ue16c" readonly property string delivery_dining: "\uea72" readonly property string density_large: "\ueba9" readonly property string density_medium: "\ueb9e" readonly property string density_small: "\ueba8" readonly property string departure_board: "\ue576" readonly property string description: "\ue873" readonly property string deselect: "\uebb6" readonly property string design_services: "\uf10a" readonly property string desk: "\uf8f4" readonly property string desktop_access_disabled: "\ue99d" readonly property string desktop_mac: "\ue30b" readonly property string desktop_windows: "\ue30c" readonly property string details: "\ue3c8" readonly property string developer_board: "\ue30d" readonly property string developer_board_off: "\ue4ff" readonly property string developer_mode: "\ue1b0" readonly property string device_hub: "\ue335" readonly property string device_thermostat: "\ue1ff" readonly property string device_unknown: "\ue339" readonly property string devices: "\ue1b1" readonly property string devices_fold: "\uebde" readonly property string devices_other: "\ue337" readonly property string dew_point: "\uf879" readonly property string dialer_sip: "\ue0bb" readonly property string dialpad: "\ue0bc" readonly property string diamond: "\uead5" readonly property string difference: "\ueb7d" readonly property string dining: "\ueff4" readonly property string dinner_dining: "\uea57" readonly property string directions: "\ue52e" readonly property string directions_bike: "\ue52f" readonly property string directions_boat: "\ue532" readonly property string directions_boat_filled: "\ueff5" readonly property string directions_bus: "\ue530" readonly property string directions_bus_filled: "\ueff6" readonly property string directions_car: "\ue531" readonly property string directions_car_filled: "\ueff7" readonly property string directions_ferry: "\ue532" readonly property string directions_off: "\uf10f" readonly property string directions_railway: "\ue534" readonly property string directions_railway_filled: "\ueff8" readonly property string directions_run: "\ue566" readonly property string directions_subway: "\ue533" readonly property string directions_subway_filled: "\ueff9" readonly property string directions_train: "\ue534" readonly property string directions_transit: "\ue535" readonly property string directions_transit_filled: "\ueffa" readonly property string directions_walk: "\ue536" readonly property string dirty_lens: "\uef4b" readonly property string disabled_by_default: "\uf230" readonly property string disabled_visible: "\ue76e" readonly property string disc_full: "\ue610" readonly property string discord: "\uea6c" readonly property string discount: "\uebc9" readonly property string display_settings: "\ueb97" readonly property string diversity_1: "\uf8d7" readonly property string diversity_2: "\uf8d8" readonly property string diversity_3: "\uf8d9" readonly property string dnd_forwardslash: "\ue611" readonly property string dns: "\ue875" readonly property string do_disturb: "\uf08c" readonly property string do_disturb_alt: "\uf08d" readonly property string do_disturb_off: "\uf08e" readonly property string do_disturb_on: "\uf08f" readonly property string do_not_disturb: "\ue612" readonly property string do_not_disturb_alt: "\ue611" readonly property string do_not_disturb_off: "\ue643" readonly property string do_not_disturb_on: "\ue644" readonly property string do_not_disturb_on_total_silence: "\ueffb" readonly property string do_not_step: "\uf19f" readonly property string do_not_touch: "\uf1b0" readonly property string dock: "\ue30e" readonly property string document_scanner: "\ue5fa" readonly property string domain: "\ue7ee" readonly property string domain_add: "\ueb62" readonly property string domain_disabled: "\ue0ef" readonly property string domain_verification: "\uef4c" readonly property string done: "\ue876" readonly property string done_all: "\ue877" readonly property string done_outline: "\ue92f" readonly property string donut_large: "\ue917" readonly property string donut_small: "\ue918" readonly property string door_back: "\ueffc" readonly property string door_front: "\ueffd" readonly property string door_sliding: "\ueffe" readonly property string doorbell: "\uefff" readonly property string double_arrow: "\uea50" readonly property string downhill_skiing: "\ue509" readonly property string download: "\uf090" readonly property string download_done: "\uf091" readonly property string download_for_offline: "\uf000" readonly property string downloading: "\uf001" readonly property string drafts: "\ue151" readonly property string drag_handle: "\ue25d" readonly property string drag_indicator: "\ue945" readonly property string draw: "\ue746" readonly property string drive_eta: "\ue613" readonly property string drive_file_move: "\ue675" readonly property string drive_file_move_outline: "\ue9a1" readonly property string drive_file_move_rtl: "\ue76d" readonly property string drive_file_rename_outline: "\ue9a2" readonly property string drive_folder_upload: "\ue9a3" readonly property string dry: "\uf1b3" readonly property string dry_cleaning: "\uea58" readonly property string duo: "\ue9a5" readonly property string dvr: "\ue1b2" readonly property string dynamic_feed: "\uea14" readonly property string dynamic_form: "\uf1bf" readonly property string e_mobiledata: "\uf002" readonly property string earbuds: "\uf003" readonly property string earbuds_battery: "\uf004" readonly property string east: "\uf1df" readonly property string eco: "\uea35" readonly property string edgesensor_high: "\uf005" readonly property string edgesensor_low: "\uf006" readonly property string edit: "\ue3c9" readonly property string edit_attributes: "\ue578" readonly property string edit_calendar: "\ue742" readonly property string edit_document: "\uf88c" readonly property string edit_location: "\ue568" readonly property string edit_location_alt: "\ue1c5" readonly property string edit_note: "\ue745" readonly property string edit_notifications: "\ue525" readonly property string edit_off: "\ue950" readonly property string edit_road: "\uef4d" readonly property string edit_square: "\uf88d" readonly property string egg: "\ueacc" readonly property string egg_alt: "\ueac8" readonly property string eject: "\ue8fb" readonly property string elderly: "\uf21a" readonly property string elderly_woman: "\ueb69" readonly property string electric_bike: "\ueb1b" readonly property string electric_bolt: "\uec1c" readonly property string electric_car: "\ueb1c" readonly property string electric_meter: "\uec1b" readonly property string electric_moped: "\ueb1d" readonly property string electric_rickshaw: "\ueb1e" readonly property string electric_scooter: "\ueb1f" readonly property string electrical_services: "\uf102" readonly property string elevator: "\uf1a0" readonly property string email: "\ue0be" readonly property string emergency: "\ue1eb" readonly property string emergency_recording: "\uebf4" readonly property string emergency_share: "\uebf6" readonly property string emoji_emotions: "\uea22" readonly property string emoji_events: "\uea23" readonly property string emoji_flags: "\uea1a" readonly property string emoji_food_beverage: "\uea1b" readonly property string emoji_nature: "\uea1c" readonly property string emoji_objects: "\uea24" readonly property string emoji_people: "\uea1d" readonly property string emoji_symbols: "\uea1e" readonly property string emoji_transportation: "\uea1f" readonly property string energy_savings_leaf: "\uec1a" readonly property string engineering: "\uea3d" readonly property string enhance_photo_translate: "\ue8fc" readonly property string enhanced_encryption: "\ue63f" readonly property string equalizer: "\ue01d" readonly property string error: "\ue000" readonly property string error_outline: "\ue001" readonly property string escalator: "\uf1a1" readonly property string escalator_warning: "\uf1ac" readonly property string euro: "\uea15" readonly property string euro_symbol: "\ue926" readonly property string ev_station: "\ue56d" readonly property string event: "\ue878" readonly property string event_available: "\ue614" readonly property string event_busy: "\ue615" readonly property string event_note: "\ue616" readonly property string event_repeat: "\ueb7b" readonly property string event_seat: "\ue903" readonly property string exit_to_app: "\ue879" readonly property string expand: "\ue94f" readonly property string expand_circle_down: "\ue7cd" readonly property string expand_less: "\ue5ce" readonly property string expand_more: "\ue5cf" readonly property string explicit: "\ue01e" readonly property string explore: "\ue87a" readonly property string explore_off: "\ue9a8" readonly property string exposure: "\ue3ca" readonly property string exposure_minus_1: "\ue3cb" readonly property string exposure_minus_2: "\ue3cc" readonly property string exposure_neg_1: "\ue3cb" readonly property string exposure_neg_2: "\ue3cc" readonly property string exposure_plus_1: "\ue3cd" readonly property string exposure_plus_2: "\ue3ce" readonly property string exposure_zero: "\ue3cf" readonly property string extension: "\ue87b" readonly property string extension_off: "\ue4f5" readonly property string face: "\ue87c" readonly property string face_2: "\uf8da" readonly property string face_3: "\uf8db" readonly property string face_4: "\uf8dc" readonly property string face_5: "\uf8dd" readonly property string face_6: "\uf8de" readonly property string face_retouching_natural: "\uef4e" readonly property string face_retouching_off: "\uf007" readonly property string facebook: "\uf234" readonly property string fact_check: "\uf0c5" readonly property string factory: "\uebbc" readonly property string family_restroom: "\uf1a2" readonly property string fast_forward: "\ue01f" readonly property string fast_rewind: "\ue020" readonly property string fastfood: "\ue57a" readonly property string favorite: "\ue87d" readonly property string favorite_border: "\ue87e" readonly property string favorite_outline: "\ue87e" readonly property string fax: "\uead8" readonly property string featured_play_list: "\ue06d" readonly property string featured_video: "\ue06e" readonly property string feed: "\uf009" readonly property string feedback: "\ue87f" readonly property string female: "\ue590" readonly property string fence: "\uf1f6" readonly property string festival: "\uea68" readonly property string fiber_dvr: "\ue05d" readonly property string fiber_manual_record: "\ue061" readonly property string fiber_new: "\ue05e" readonly property string fiber_pin: "\ue06a" readonly property string fiber_smart_record: "\ue062" readonly property string file_copy: "\ue173" readonly property string file_download: "\ue2c4" readonly property string file_download_done: "\ue9aa" readonly property string file_download_off: "\ue4fe" readonly property string file_open: "\ueaf3" readonly property string file_present: "\uea0e" readonly property string file_upload: "\ue2c6" readonly property string file_upload_off: "\uf886" readonly property string filter: "\ue3d3" readonly property string filter_1: "\ue3d0" readonly property string filter_2: "\ue3d1" readonly property string filter_3: "\ue3d2" readonly property string filter_4: "\ue3d4" readonly property string filter_5: "\ue3d5" readonly property string filter_6: "\ue3d6" readonly property string filter_7: "\ue3d7" readonly property string filter_8: "\ue3d8" readonly property string filter_9: "\ue3d9" readonly property string filter_9_plus: "\ue3da" readonly property string filter_alt: "\uef4f" readonly property string filter_alt_off: "\ueb32" readonly property string filter_b_and_w: "\ue3db" readonly property string filter_center_focus: "\ue3dc" readonly property string filter_drama: "\ue3dd" readonly property string filter_frames: "\ue3de" readonly property string filter_hdr: "\ue3df" readonly property string filter_list: "\ue152" readonly property string filter_list_alt: "\ue94e" readonly property string filter_list_off: "\ueb57" readonly property string filter_none: "\ue3e0" readonly property string filter_tilt_shift: "\ue3e2" readonly property string filter_vintage: "\ue3e3" readonly property string find_in_page: "\ue880" readonly property string find_replace: "\ue881" readonly property string fingerprint: "\ue90d" readonly property string fire_extinguisher: "\uf1d8" readonly property string fire_hydrant: "\uf1a3" readonly property string fire_hydrant_alt: "\uf8f1" readonly property string fire_truck: "\uf8f2" readonly property string fireplace: "\uea43" readonly property string first_page: "\ue5dc" readonly property string fit_screen: "\uea10" readonly property string fitbit: "\ue82b" readonly property string fitness_center: "\ueb43" readonly property string flag: "\ue153" readonly property string flag_circle: "\ueaf8" readonly property string flaky: "\uef50" readonly property string flare: "\ue3e4" readonly property string flash_auto: "\ue3e5" readonly property string flash_off: "\ue3e6" readonly property string flash_on: "\ue3e7" readonly property string flashlight_off: "\uf00a" readonly property string flashlight_on: "\uf00b" readonly property string flatware: "\uf00c" readonly property string flight: "\ue539" readonly property string flight_class: "\ue7cb" readonly property string flight_land: "\ue904" readonly property string flight_takeoff: "\ue905" readonly property string flip: "\ue3e8" readonly property string flip_camera_android: "\uea37" readonly property string flip_camera_ios: "\uea38" readonly property string flip_to_back: "\ue882" readonly property string flip_to_front: "\ue883" readonly property string flood: "\uebe6" readonly property string flourescent: "\uec31" readonly property string flourescent2: "\uf00d" readonly property string fluorescent: "\uec31" readonly property string flutter_dash: "\ue00b" readonly property string fmd_bad: "\uf00e" readonly property string fmd_good: "\uf00f" readonly property string foggy: "\ue818" readonly property string folder: "\ue2c7" readonly property string folder_copy: "\uebbd" readonly property string folder_delete: "\ueb34" readonly property string folder_off: "\ueb83" readonly property string folder_open: "\ue2c8" readonly property string folder_shared: "\ue2c9" readonly property string folder_special: "\ue617" readonly property string folder_zip: "\ueb2c" readonly property string follow_the_signs: "\uf222" readonly property string font_download: "\ue167" readonly property string font_download_off: "\ue4f9" readonly property string food_bank: "\uf1f2" readonly property string forest: "\uea99" readonly property string fork_left: "\ueba0" readonly property string fork_right: "\uebac" readonly property string forklift: "\uf868" readonly property string format_align_center: "\ue234" readonly property string format_align_justify: "\ue235" readonly property string format_align_left: "\ue236" readonly property string format_align_right: "\ue237" readonly property string format_bold: "\ue238" readonly property string format_clear: "\ue239" readonly property string format_color_fill: "\ue23a" readonly property string format_color_reset: "\ue23b" readonly property string format_color_text: "\ue23c" readonly property string format_indent_decrease: "\ue23d" readonly property string format_indent_increase: "\ue23e" readonly property string format_italic: "\ue23f" readonly property string format_line_spacing: "\ue240" readonly property string format_list_bulleted: "\ue241" readonly property string format_list_bulleted_add: "\uf849" readonly property string format_list_numbered: "\ue242" readonly property string format_list_numbered_rtl: "\ue267" readonly property string format_overline: "\ueb65" readonly property string format_paint: "\ue243" readonly property string format_quote: "\ue244" readonly property string format_shapes: "\ue25e" readonly property string format_size: "\ue245" readonly property string format_strikethrough: "\ue246" readonly property string format_textdirection_l_to_r: "\ue247" readonly property string format_textdirection_r_to_l: "\ue248" readonly property string format_underline: "\ue249" readonly property string format_underlined: "\ue249" readonly property string fort: "\ueaad" readonly property string forum: "\ue0bf" readonly property string forward: "\ue154" readonly property string forward_10: "\ue056" readonly property string forward_30: "\ue057" readonly property string forward_5: "\ue058" readonly property string forward_to_inbox: "\uf187" readonly property string foundation: "\uf200" readonly property string free_breakfast: "\ueb44" readonly property string free_cancellation: "\ue748" readonly property string front_hand: "\ue769" readonly property string front_loader: "\uf869" readonly property string fullscreen: "\ue5d0" readonly property string fullscreen_exit: "\ue5d1" readonly property string functions: "\ue24a" readonly property string g_mobiledata: "\uf010" readonly property string g_translate: "\ue927" readonly property string gamepad: "\ue30f" readonly property string games: "\ue021" readonly property string garage: "\uf011" readonly property string gas_meter: "\uec19" readonly property string gavel: "\ue90e" readonly property string generating_tokens: "\ue749" readonly property string gesture: "\ue155" readonly property string get_app: "\ue884" readonly property string gif: "\ue908" readonly property string gif_box: "\ue7a3" readonly property string girl: "\ueb68" readonly property string gite: "\ue58b" readonly property string goat: "\u10fffd" readonly property string golf_course: "\ueb45" readonly property string gpp_bad: "\uf012" readonly property string gpp_good: "\uf013" readonly property string gpp_maybe: "\uf014" readonly property string gps_fixed: "\ue1b3" readonly property string gps_not_fixed: "\ue1b4" readonly property string gps_off: "\ue1b5" readonly property string grade: "\ue885" readonly property string gradient: "\ue3e9" readonly property string grading: "\uea4f" readonly property string grain: "\ue3ea" readonly property string graphic_eq: "\ue1b8" readonly property string grass: "\uf205" readonly property string grid_3x3: "\uf015" readonly property string grid_4x4: "\uf016" readonly property string grid_goldenratio: "\uf017" readonly property string grid_off: "\ue3eb" readonly property string grid_on: "\ue3ec" readonly property string grid_view: "\ue9b0" readonly property string group: "\ue7ef" readonly property string group_add: "\ue7f0" readonly property string group_off: "\ue747" readonly property string group_remove: "\ue7ad" readonly property string group_work: "\ue886" readonly property string groups: "\uf233" readonly property string groups_2: "\uf8df" readonly property string groups_3: "\uf8e0" readonly property string h_mobiledata: "\uf018" readonly property string h_plus_mobiledata: "\uf019" readonly property string hail: "\ue9b1" readonly property string handshake: "\uebcb" readonly property string handyman: "\uf10b" readonly property string hardware: "\uea59" readonly property string hd: "\ue052" readonly property string hdr_auto: "\uf01a" readonly property string hdr_auto_select: "\uf01b" readonly property string hdr_enhanced_select: "\uef51" readonly property string hdr_off: "\ue3ed" readonly property string hdr_off_select: "\uf01c" readonly property string hdr_on: "\ue3ee" readonly property string hdr_on_select: "\uf01d" readonly property string hdr_plus: "\uf01e" readonly property string hdr_strong: "\ue3f1" readonly property string hdr_weak: "\ue3f2" readonly property string headphones: "\uf01f" readonly property string headphones_battery: "\uf020" readonly property string headset: "\ue310" readonly property string headset_mic: "\ue311" readonly property string headset_off: "\ue33a" readonly property string healing: "\ue3f3" readonly property string health_and_safety: "\ue1d5" readonly property string hearing: "\ue023" readonly property string hearing_disabled: "\uf104" readonly property string heart_broken: "\ueac2" readonly property string heat_pump: "\uec18" readonly property string height: "\uea16" readonly property string help: "\ue887" readonly property string help_center: "\uf1c0" readonly property string help_outline: "\ue8fd" readonly property string hevc: "\uf021" readonly property string hexagon: "\ueb39" readonly property string hide_image: "\uf022" readonly property string hide_source: "\uf023" readonly property string high_quality: "\ue024" readonly property string highlight: "\ue25f" readonly property string highlight_alt: "\uef52" readonly property string highlight_off: "\ue888" readonly property string highlight_remove: "\ue888" readonly property string hiking: "\ue50a" readonly property string history: "\ue889" readonly property string history_edu: "\uea3e" readonly property string history_toggle_off: "\uf17d" readonly property string hive: "\ueaa6" readonly property string hls: "\ueb8a" readonly property string hls_off: "\ueb8c" readonly property string holiday_village: "\ue58a" readonly property string home: "\ue88a" readonly property string home_filled: "\ue9b2" readonly property string home_max: "\uf024" readonly property string home_mini: "\uf025" readonly property string home_repair_service: "\uf100" readonly property string home_work: "\uea09" readonly property string horizontal_distribute: "\ue014" readonly property string horizontal_rule: "\uf108" readonly property string horizontal_split: "\ue947" readonly property string hot_tub: "\ueb46" readonly property string hotel: "\ue53a" readonly property string hotel_class: "\ue743" readonly property string hourglass_bottom: "\uea5c" readonly property string hourglass_disabled: "\uef53" readonly property string hourglass_empty: "\ue88b" readonly property string hourglass_full: "\ue88c" readonly property string hourglass_top: "\uea5b" readonly property string house: "\uea44" readonly property string house_siding: "\uf202" readonly property string houseboat: "\ue584" readonly property string how_to_reg: "\ue174" readonly property string how_to_vote: "\ue175" readonly property string html: "\ueb7e" readonly property string http: "\ue902" readonly property string https: "\ue88d" readonly property string hub: "\ue9f4" readonly property string hvac: "\uf10e" readonly property string ice_skating: "\ue50b" readonly property string icecream: "\uea69" readonly property string image: "\ue3f4" readonly property string image_aspect_ratio: "\ue3f5" readonly property string image_not_supported: "\uf116" readonly property string image_search: "\ue43f" readonly property string imagesearch_roller: "\ue9b4" readonly property string import_contacts: "\ue0e0" readonly property string import_export: "\ue0c3" readonly property string important_devices: "\ue912" readonly property string inbox: "\ue156" readonly property string incomplete_circle: "\ue79b" readonly property string indeterminate_check_box: "\ue909" readonly property string info: "\ue88e" readonly property string info_outline: "\ue88f" readonly property string input: "\ue890" readonly property string insert_chart: "\ue24b" readonly property string insert_chart_outlined: "\ue26a" readonly property string insert_comment: "\ue24c" readonly property string insert_drive_file: "\ue24d" readonly property string insert_emoticon: "\ue24e" readonly property string insert_invitation: "\ue24f" readonly property string insert_link: "\ue250" readonly property string insert_page_break: "\ueaca" readonly property string insert_photo: "\ue251" readonly property string insights: "\uf092" readonly property string install_desktop: "\ueb71" readonly property string install_mobile: "\ueb72" readonly property string integration_instructions: "\uef54" readonly property string interests: "\ue7c8" readonly property string interpreter_mode: "\ue83b" readonly property string inventory: "\ue179" readonly property string inventory_2: "\ue1a1" readonly property string invert_colors: "\ue891" readonly property string invert_colors_off: "\ue0c4" readonly property string invert_colors_on: "\ue891" readonly property string ios_share: "\ue6b8" readonly property string iron: "\ue583" readonly property string iso: "\ue3f6" readonly property string javascript: "\ueb7c" readonly property string join_full: "\ueaeb" readonly property string join_inner: "\ueaf4" readonly property string join_left: "\ueaf2" readonly property string join_right: "\ueaea" readonly property string kayaking: "\ue50c" readonly property string kebab_dining: "\ue842" readonly property string key: "\ue73c" readonly property string key_off: "\ueb84" readonly property string keyboard: "\ue312" readonly property string keyboard_alt: "\uf028" readonly property string keyboard_arrow_down: "\ue313" readonly property string keyboard_arrow_left: "\ue314" readonly property string keyboard_arrow_right: "\ue315" readonly property string keyboard_arrow_up: "\ue316" readonly property string keyboard_backspace: "\ue317" readonly property string keyboard_capslock: "\ue318" readonly property string keyboard_command: "\ueae0" readonly property string keyboard_command_key: "\ueae7" readonly property string keyboard_control: "\ue5d3" readonly property string keyboard_control_key: "\ueae6" readonly property string keyboard_double_arrow_down: "\uead0" readonly property string keyboard_double_arrow_left: "\ueac3" readonly property string keyboard_double_arrow_right: "\ueac9" readonly property string keyboard_double_arrow_up: "\ueacf" readonly property string keyboard_hide: "\ue31a" readonly property string keyboard_option: "\ueadf" readonly property string keyboard_option_key: "\ueae8" readonly property string keyboard_return: "\ue31b" readonly property string keyboard_tab: "\ue31c" readonly property string keyboard_voice: "\ue31d" readonly property string king_bed: "\uea45" readonly property string kitchen: "\ueb47" readonly property string kitesurfing: "\ue50d" readonly property string label: "\ue892" readonly property string label_important: "\ue937" readonly property string label_important_outline: "\ue948" readonly property string label_off: "\ue9b6" readonly property string label_outline: "\ue893" readonly property string lan: "\ueb2f" readonly property string landscape: "\ue3f7" readonly property string landslide: "\uebd7" readonly property string language: "\ue894" readonly property string laptop: "\ue31e" readonly property string laptop_chromebook: "\ue31f" readonly property string laptop_mac: "\ue320" readonly property string laptop_windows: "\ue321" readonly property string last_page: "\ue5dd" readonly property string launch: "\ue895" readonly property string layers: "\ue53b" readonly property string layers_clear: "\ue53c" readonly property string leaderboard: "\uf20c" readonly property string leak_add: "\ue3f8" readonly property string leak_remove: "\ue3f9" readonly property string leave_bags_at_home: "\uf21b" readonly property string legend_toggle: "\uf11b" readonly property string lens: "\ue3fa" readonly property string lens_blur: "\uf029" readonly property string library_add: "\ue02e" readonly property string library_add_check: "\ue9b7" readonly property string library_books: "\ue02f" readonly property string library_music: "\ue030" readonly property string light: "\uf02a" readonly property string light_mode: "\ue518" readonly property string lightbulb: "\ue0f0" readonly property string lightbulb_circle: "\uebfe" readonly property string lightbulb_outline: "\ue90f" readonly property string line_axis: "\uea9a" readonly property string line_style: "\ue919" readonly property string line_weight: "\ue91a" readonly property string linear_scale: "\ue260" readonly property string link: "\ue157" readonly property string link_off: "\ue16f" readonly property string linked_camera: "\ue438" readonly property string liquor: "\uea60" readonly property string list: "\ue896" readonly property string list_alt: "\ue0ee" readonly property string live_help: "\ue0c6" readonly property string live_tv: "\ue639" readonly property string living: "\uf02b" readonly property string local_activity: "\ue53f" readonly property string local_airport: "\ue53d" readonly property string local_atm: "\ue53e" readonly property string local_attraction: "\ue53f" readonly property string local_bar: "\ue540" readonly property string local_cafe: "\ue541" readonly property string local_car_wash: "\ue542" readonly property string local_convenience_store: "\ue543" readonly property string local_dining: "\ue556" readonly property string local_drink: "\ue544" readonly property string local_fire_department: "\uef55" readonly property string local_florist: "\ue545" readonly property string local_gas_station: "\ue546" readonly property string local_grocery_store: "\ue547" readonly property string local_hospital: "\ue548" readonly property string local_hotel: "\ue549" readonly property string local_laundry_service: "\ue54a" readonly property string local_library: "\ue54b" readonly property string local_mall: "\ue54c" readonly property string local_movies: "\ue54d" readonly property string local_offer: "\ue54e" readonly property string local_parking: "\ue54f" readonly property string local_pharmacy: "\ue550" readonly property string local_phone: "\ue551" readonly property string local_pizza: "\ue552" readonly property string local_play: "\ue553" readonly property string local_police: "\uef56" readonly property string local_post_office: "\ue554" readonly property string local_print_shop: "\ue555" readonly property string local_printshop: "\ue555" readonly property string local_restaurant: "\ue556" readonly property string local_see: "\ue557" readonly property string local_shipping: "\ue558" readonly property string local_taxi: "\ue559" readonly property string location_city: "\ue7f1" readonly property string location_disabled: "\ue1b6" readonly property string location_history: "\ue55a" readonly property string location_off: "\ue0c7" readonly property string location_on: "\ue0c8" readonly property string location_pin: "\uf1db" readonly property string location_searching: "\ue1b7" readonly property string lock: "\ue897" readonly property string lock_clock: "\uef57" readonly property string lock_open: "\ue898" readonly property string lock_outline: "\ue899" readonly property string lock_person: "\uf8f3" readonly property string lock_reset: "\ueade" readonly property string login: "\uea77" readonly property string logo_dev: "\uead6" readonly property string logout: "\ue9ba" readonly property string looks: "\ue3fc" readonly property string looks_3: "\ue3fb" readonly property string looks_4: "\ue3fd" readonly property string looks_5: "\ue3fe" readonly property string looks_6: "\ue3ff" readonly property string looks_one: "\ue400" readonly property string looks_two: "\ue401" readonly property string loop: "\ue028" readonly property string loupe: "\ue402" readonly property string low_priority: "\ue16d" readonly property string loyalty: "\ue89a" readonly property string lte_mobiledata: "\uf02c" readonly property string lte_plus_mobiledata: "\uf02d" readonly property string luggage: "\uf235" readonly property string lunch_dining: "\uea61" readonly property string lyrics: "\uec0b" readonly property string macro_off: "\uf8d2" readonly property string mail: "\ue158" readonly property string mail_lock: "\uec0a" readonly property string mail_outline: "\ue0e1" readonly property string male: "\ue58e" readonly property string man: "\ue4eb" readonly property string man_2: "\uf8e1" readonly property string man_3: "\uf8e2" readonly property string man_4: "\uf8e3" readonly property string manage_accounts: "\uf02e" readonly property string manage_history: "\uebe7" readonly property string manage_search: "\uf02f" readonly property string map: "\ue55b" readonly property string maps_home_work: "\uf030" readonly property string maps_ugc: "\uef58" readonly property string margin: "\ue9bb" readonly property string mark_as_unread: "\ue9bc" readonly property string mark_chat_read: "\uf18b" readonly property string mark_chat_unread: "\uf189" readonly property string mark_email_read: "\uf18c" readonly property string mark_email_unread: "\uf18a" readonly property string mark_unread_chat_alt: "\ueb9d" readonly property string markunread: "\ue159" readonly property string markunread_mailbox: "\ue89b" readonly property string masks: "\uf218" readonly property string maximize: "\ue930" readonly property string media_bluetooth_off: "\uf031" readonly property string media_bluetooth_on: "\uf032" readonly property string mediation: "\uefa7" readonly property string medical_information: "\uebed" readonly property string medical_services: "\uf109" readonly property string medication: "\uf033" readonly property string medication_liquid: "\uea87" readonly property string meeting_room: "\ueb4f" readonly property string memory: "\ue322" readonly property string menu: "\ue5d2" readonly property string menu_book: "\uea19" readonly property string menu_open: "\ue9bd" readonly property string merge: "\ueb98" readonly property string merge_type: "\ue252" readonly property string message: "\ue0c9" readonly property string messenger: "\ue0ca" readonly property string messenger_outline: "\ue0cb" readonly property string mic: "\ue029" readonly property string mic_external_off: "\uef59" readonly property string mic_external_on: "\uef5a" readonly property string mic_none: "\ue02a" readonly property string mic_off: "\ue02b" readonly property string microwave: "\uf204" readonly property string military_tech: "\uea3f" readonly property string minimize: "\ue931" readonly property string minor_crash: "\uebf1" readonly property string miscellaneous_services: "\uf10c" readonly property string missed_video_call: "\ue073" readonly property string mms: "\ue618" readonly property string mobile_friendly: "\ue200" readonly property string mobile_off: "\ue201" readonly property string mobile_screen_share: "\ue0e7" readonly property string mobiledata_off: "\uf034" readonly property string mode: "\uf097" readonly property string mode_comment: "\ue253" readonly property string mode_edit: "\ue254" readonly property string mode_edit_outline: "\uf035" readonly property string mode_fan_off: "\uec17" readonly property string mode_night: "\uf036" readonly property string mode_of_travel: "\ue7ce" readonly property string mode_standby: "\uf037" readonly property string model_training: "\uf0cf" readonly property string monetization_on: "\ue263" readonly property string money: "\ue57d" readonly property string money_off: "\ue25c" readonly property string money_off_csred: "\uf038" readonly property string monitor: "\uef5b" readonly property string monitor_heart: "\ueaa2" readonly property string monitor_weight: "\uf039" readonly property string monochrome_photos: "\ue403" readonly property string mood: "\ue7f2" readonly property string mood_bad: "\ue7f3" readonly property string moped: "\ueb28" readonly property string more: "\ue619" readonly property string more_horiz: "\ue5d3" readonly property string more_time: "\uea5d" readonly property string more_vert: "\ue5d4" readonly property string mosque: "\ueab2" readonly property string motion_photos_auto: "\uf03a" readonly property string motion_photos_off: "\ue9c0" readonly property string motion_photos_on: "\ue9c1" readonly property string motion_photos_pause: "\uf227" readonly property string motion_photos_paused: "\ue9c2" readonly property string motorcycle: "\ue91b" readonly property string mouse: "\ue323" readonly property string move_down: "\ueb61" readonly property string move_to_inbox: "\ue168" readonly property string move_up: "\ueb64" readonly property string movie: "\ue02c" readonly property string movie_creation: "\ue404" readonly property string movie_edit: "\uf840" readonly property string movie_filter: "\ue43a" readonly property string moving: "\ue501" readonly property string mp: "\ue9c3" readonly property string multiline_chart: "\ue6df" readonly property string multiple_stop: "\uf1b9" readonly property string multitrack_audio: "\ue1b8" readonly property string museum: "\uea36" readonly property string music_note: "\ue405" readonly property string music_off: "\ue440" readonly property string music_video: "\ue063" readonly property string my_library_add: "\ue02e" readonly property string my_library_books: "\ue02f" readonly property string my_library_music: "\ue030" readonly property string my_location: "\ue55c" readonly property string nat: "\uef5c" readonly property string nature: "\ue406" readonly property string nature_people: "\ue407" readonly property string navigate_before: "\ue408" readonly property string navigate_next: "\ue409" readonly property string navigation: "\ue55d" readonly property string near_me: "\ue569" readonly property string near_me_disabled: "\uf1ef" readonly property string nearby_error: "\uf03b" readonly property string nearby_off: "\uf03c" readonly property string nest_cam_wired_stand: "\uec16" readonly property string network_cell: "\ue1b9" readonly property string network_check: "\ue640" readonly property string network_locked: "\ue61a" readonly property string network_ping: "\uebca" readonly property string network_wifi: "\ue1ba" readonly property string network_wifi_1_bar: "\uebe4" readonly property string network_wifi_2_bar: "\uebd6" readonly property string network_wifi_3_bar: "\uebe1" readonly property string new_label: "\ue609" readonly property string new_releases: "\ue031" readonly property string newspaper: "\ueb81" readonly property string next_plan: "\uef5d" readonly property string next_week: "\ue16a" readonly property string nfc: "\ue1bb" readonly property string night_shelter: "\uf1f1" readonly property string nightlife: "\uea62" readonly property string nightlight: "\uf03d" readonly property string nightlight_round: "\uef5e" readonly property string nights_stay: "\uea46" readonly property string no_accounts: "\uf03e" readonly property string no_adult_content: "\uf8fe" readonly property string no_backpack: "\uf237" readonly property string no_cell: "\uf1a4" readonly property string no_crash: "\uebf0" readonly property string no_drinks: "\uf1a5" readonly property string no_encryption: "\ue641" readonly property string no_encryption_gmailerrorred: "\uf03f" readonly property string no_flash: "\uf1a6" readonly property string no_food: "\uf1a7" readonly property string no_luggage: "\uf23b" readonly property string no_meals: "\uf1d6" readonly property string no_meals_ouline: "\uf229" readonly property string no_meeting_room: "\ueb4e" readonly property string no_photography: "\uf1a8" readonly property string no_sim: "\ue0cc" readonly property string no_stroller: "\uf1af" readonly property string no_transfer: "\uf1d5" readonly property string noise_aware: "\uebec" readonly property string noise_control_off: "\uebf3" readonly property string nordic_walking: "\ue50e" readonly property string north: "\uf1e0" readonly property string north_east: "\uf1e1" readonly property string north_west: "\uf1e2" readonly property string not_accessible: "\uf0fe" readonly property string not_interested: "\ue033" readonly property string not_listed_location: "\ue575" readonly property string not_started: "\uf0d1" readonly property string note: "\ue06f" readonly property string note_add: "\ue89c" readonly property string note_alt: "\uf040" readonly property string notes: "\ue26c" readonly property string notification_add: "\ue399" readonly property string notification_important: "\ue004" readonly property string notifications: "\ue7f4" readonly property string notifications_active: "\ue7f7" readonly property string notifications_none: "\ue7f5" readonly property string notifications_off: "\ue7f6" readonly property string notifications_on: "\ue7f7" readonly property string notifications_paused: "\ue7f8" readonly property string now_wallpaper: "\ue1bc" readonly property string now_widgets: "\ue1bd" readonly property string numbers: "\ueac7" readonly property string offline_bolt: "\ue932" readonly property string offline_pin: "\ue90a" readonly property string offline_share: "\ue9c5" readonly property string oil_barrel: "\uec15" readonly property string on_device_training: "\uebfd" readonly property string ondemand_video: "\ue63a" readonly property string online_prediction: "\uf0eb" readonly property string opacity_: "\ue91c" readonly property string open_in_browser: "\ue89d" readonly property string open_in_full: "\uf1ce" readonly property string open_in_new: "\ue89e" readonly property string open_in_new_off: "\ue4f6" readonly property string open_with: "\ue89f" readonly property string other_houses: "\ue58c" readonly property string outbond: "\uf228" readonly property string outbound: "\ue1ca" readonly property string outbox: "\uef5f" readonly property string outdoor_grill: "\uea47" readonly property string outgoing_mail: "\uf0d2" readonly property string outlet: "\uf1d4" readonly property string outlined_flag: "\ue16e" readonly property string output: "\uebbe" readonly property string padding: "\ue9c8" readonly property string pages: "\ue7f9" readonly property string pageview: "\ue8a0" readonly property string paid: "\uf041" readonly property string palette: "\ue40a" readonly property string pallet: "\uf86a" readonly property string pan_tool: "\ue925" readonly property string pan_tool_alt: "\uebb9" readonly property string panorama: "\ue40b" readonly property string panorama_fish_eye: "\ue40c" readonly property string panorama_fisheye: "\ue40c" readonly property string panorama_horizontal: "\ue40d" readonly property string panorama_horizontal_select: "\uef60" readonly property string panorama_photosphere: "\ue9c9" readonly property string panorama_photosphere_select: "\ue9ca" readonly property string panorama_vertical: "\ue40e" readonly property string panorama_vertical_select: "\uef61" readonly property string panorama_wide_angle: "\ue40f" readonly property string panorama_wide_angle_select: "\uef62" readonly property string paragliding: "\ue50f" readonly property string park: "\uea63" readonly property string party_mode: "\ue7fa" readonly property string pwd: "\uf042" readonly property string pattern: "\uf043" readonly property string pause: "\ue034" readonly property string pause_circle: "\ue1a2" readonly property string pause_circle_filled: "\ue035" readonly property string pause_circle_outline: "\ue036" readonly property string pause_presentation: "\ue0ea" readonly property string payment: "\ue8a1" readonly property string payments: "\uef63" readonly property string paypal: "\uea8d" readonly property string pedal_bike: "\ueb29" readonly property string pending: "\uef64" readonly property string pending_actions: "\uf1bb" readonly property string pentagon: "\ueb50" readonly property string people: "\ue7fb" readonly property string people_alt: "\uea21" readonly property string people_outline: "\ue7fc" readonly property string percent: "\ueb58" readonly property string perm_camera_mic: "\ue8a2" readonly property string perm_contact_cal: "\ue8a3" readonly property string perm_contact_calendar: "\ue8a3" readonly property string perm_data_setting: "\ue8a4" readonly property string perm_device_info: "\ue8a5" readonly property string perm_device_information: "\ue8a5" readonly property string perm_identity: "\ue8a6" readonly property string perm_media: "\ue8a7" readonly property string perm_phone_msg: "\ue8a8" readonly property string perm_scan_wifi: "\ue8a9" readonly property string person: "\ue7fd" readonly property string person_2: "\uf8e4" readonly property string person_3: "\uf8e5" readonly property string person_4: "\uf8e6" readonly property string person_add: "\ue7fe" readonly property string person_add_alt: "\uea4d" readonly property string person_add_alt_1: "\uef65" readonly property string person_add_disabled: "\ue9cb" readonly property string person_off: "\ue510" readonly property string person_outline: "\ue7ff" readonly property string person_pin: "\ue55a" readonly property string person_pin_circle: "\ue56a" readonly property string person_remove: "\uef66" readonly property string person_remove_alt_1: "\uef67" readonly property string person_search: "\uf106" readonly property string personal_injury: "\ue6da" readonly property string personal_video: "\ue63b" readonly property string pest_control: "\uf0fa" readonly property string pest_control_rodent: "\uf0fd" readonly property string pets: "\ue91d" readonly property string phishing: "\uead7" readonly property string phone: "\ue0cd" readonly property string phone_android: "\ue324" readonly property string phone_bluetooth_speaker: "\ue61b" readonly property string phone_callback: "\ue649" readonly property string phone_disabled: "\ue9cc" readonly property string phone_enabled: "\ue9cd" readonly property string phone_forwarded: "\ue61c" readonly property string phone_in_talk: "\ue61d" readonly property string phone_iphone: "\ue325" readonly property string phone_locked: "\ue61e" readonly property string phone_missed: "\ue61f" readonly property string phone_paused: "\ue620" readonly property string phonelink: "\ue326" readonly property string phonelink_erase: "\ue0db" readonly property string phonelink_lock: "\ue0dc" readonly property string phonelink_off: "\ue327" readonly property string phonelink_ring: "\ue0dd" readonly property string phonelink_setup: "\ue0de" readonly property string photo: "\ue410" readonly property string photo_album: "\ue411" readonly property string photo_camera: "\ue412" readonly property string photo_camera_back: "\uef68" readonly property string photo_camera_front: "\uef69" readonly property string photo_filter: "\ue43b" readonly property string photo_library: "\ue413" readonly property string photo_size_select_actual: "\ue432" readonly property string photo_size_select_large: "\ue433" readonly property string photo_size_select_small: "\ue434" readonly property string php: "\ueb8f" readonly property string piano: "\ue521" readonly property string piano_off: "\ue520" readonly property string picture_as_pdf: "\ue415" readonly property string picture_in_picture: "\ue8aa" readonly property string picture_in_picture_alt: "\ue911" readonly property string pie_chart: "\ue6c4" readonly property string pie_chart_outline: "\uf044" readonly property string pie_chart_outlined: "\ue6c5" readonly property string pin: "\uf045" readonly property string pin_drop: "\ue55e" readonly property string pin_end: "\ue767" readonly property string pin_invoke: "\ue763" readonly property string pinch: "\ueb38" readonly property string pivot_table_chart: "\ue9ce" readonly property string pix: "\ueaa3" readonly property string place: "\ue55f" readonly property string plagiarism: "\uea5a" readonly property string play_arrow: "\ue037" readonly property string play_circle: "\ue1c4" readonly property string play_circle_fill: "\ue038" readonly property string play_circle_filled: "\ue038" readonly property string play_circle_outline: "\ue039" readonly property string play_disabled: "\uef6a" readonly property string play_for_work: "\ue906" readonly property string play_lesson: "\uf047" readonly property string playlist_add: "\ue03b" readonly property string playlist_add_check: "\ue065" readonly property string playlist_add_check_circle: "\ue7e6" readonly property string playlist_add_circle: "\ue7e5" readonly property string playlist_play: "\ue05f" readonly property string playlist_remove: "\ueb80" readonly property string plumbing: "\uf107" readonly property string plus_one: "\ue800" readonly property string podcasts: "\uf048" readonly property string point_of_sale: "\uf17e" readonly property string policy: "\uea17" readonly property string poll: "\ue801" readonly property string polyline: "\uebbb" readonly property string polymer: "\ue8ab" readonly property string pool: "\ueb48" readonly property string portable_wifi_off: "\ue0ce" readonly property string portrait: "\ue416" readonly property string post_add: "\uea20" readonly property string power: "\ue63c" readonly property string power_input: "\ue336" readonly property string power_off: "\ue646" readonly property string power_settings_new: "\ue8ac" readonly property string precision_manufacturing: "\uf049" readonly property string pregnant_woman: "\ue91e" readonly property string present_to_all: "\ue0df" readonly property string preview: "\uf1c5" readonly property string price_change: "\uf04a" readonly property string price_check: "\uf04b" readonly property string print_: "\ue8ad" readonly property string print_disabled: "\ue9cf" readonly property string priority_high: "\ue645" readonly property string privacy_tip: "\uf0dc" readonly property string private_connectivity: "\ue744" readonly property string production_quantity_limits: "\ue1d1" readonly property string propane: "\uec14" readonly property string propane_tank: "\uec13" readonly property string psychology: "\uea4a" readonly property string psychology_alt: "\uf8ea" readonly property string public_: "\ue80b" readonly property string public_off: "\uf1ca" readonly property string publish: "\ue255" readonly property string published_with_changes: "\uf232" readonly property string punch_clock: "\ueaa8" readonly property string push_pin: "\uf10d" readonly property string qr_code: "\uef6b" readonly property string qr_code_2: "\ue00a" readonly property string qr_code_scanner: "\uf206" readonly property string query_builder: "\ue8ae" readonly property string query_stats: "\ue4fc" readonly property string question_answer: "\ue8af" readonly property string question_mark: "\ueb8b" readonly property string queue: "\ue03c" readonly property string queue_music: "\ue03d" readonly property string queue_play_next: "\ue066" readonly property string quick_contacts_dialer: "\ue0cf" readonly property string quick_contacts_mail: "\ue0d0" readonly property string quickreply: "\uef6c" readonly property string quiz: "\uf04c" readonly property string quora: "\uea98" readonly property string r_mobiledata: "\uf04d" readonly property string radar: "\uf04e" readonly property string radio: "\ue03e" readonly property string radio_button_checked: "\ue837" readonly property string radio_button_off: "\ue836" readonly property string radio_button_on: "\ue837" readonly property string radio_button_unchecked: "\ue836" readonly property string railway_alert: "\ue9d1" readonly property string ramen_dining: "\uea64" readonly property string ramp_left: "\ueb9c" readonly property string ramp_right: "\ueb96" readonly property string rate_review: "\ue560" readonly property string raw_off: "\uf04f" readonly property string raw_on: "\uf050" readonly property string read_more: "\uef6d" readonly property string real_estate_agent: "\ue73a" readonly property string rebase_edit: "\uf846" readonly property string receipt: "\ue8b0" readonly property string receipt_long: "\uef6e" readonly property string recent_actors: "\ue03f" readonly property string recommend: "\ue9d2" readonly property string record_voice_over: "\ue91f" readonly property string rectangle: "\ueb54" readonly property string recycling: "\ue760" readonly property string reddit: "\ueaa0" readonly property string redeem: "\ue8b1" readonly property string redo: "\ue15a" readonly property string reduce_capacity: "\uf21c" readonly property string refresh: "\ue5d5" readonly property string remember_me: "\uf051" readonly property string remove: "\ue15b" readonly property string remove_circle: "\ue15c" readonly property string remove_circle_outline: "\ue15d" readonly property string remove_done: "\ue9d3" readonly property string remove_from_queue: "\ue067" readonly property string remove_moderator: "\ue9d4" readonly property string remove_red_eye: "\ue417" readonly property string remove_road: "\uebfc" readonly property string remove_shopping_cart: "\ue928" readonly property string reorder: "\ue8fe" readonly property string repartition: "\uf8e8" readonly property string repeat: "\ue040" readonly property string repeat_on: "\ue9d6" readonly property string repeat_one: "\ue041" readonly property string repeat_one_on: "\ue9d7" readonly property string replay: "\ue042" readonly property string replay_10: "\ue059" readonly property string replay_30: "\ue05a" readonly property string replay_5: "\ue05b" readonly property string replay_circle_filled: "\ue9d8" readonly property string reply: "\ue15e" readonly property string reply_all: "\ue15f" readonly property string report: "\ue160" readonly property string report_gmailerrorred: "\uf052" readonly property string report_off: "\ue170" readonly property string report_problem: "\ue8b2" readonly property string request_page: "\uf22c" readonly property string request_quote: "\uf1b6" readonly property string reset_tv: "\ue9d9" readonly property string restart_alt: "\uf053" readonly property string restaurant: "\ue56c" readonly property string restaurant_menu: "\ue561" readonly property string restore: "\ue8b3" readonly property string restore_from_trash: "\ue938" readonly property string restore_page: "\ue929" readonly property string reviews: "\uf054" readonly property string rice_bowl: "\uf1f5" readonly property string ring_volume: "\ue0d1" readonly property string rocket: "\ueba5" readonly property string rocket_launch: "\ueb9b" readonly property string roller_shades: "\uec12" readonly property string roller_shades_closed: "\uec11" readonly property string roller_skating: "\uebcd" readonly property string roofing: "\uf201" readonly property string room: "\ue8b4" readonly property string room_preferences: "\uf1b8" readonly property string room_service: "\ueb49" readonly property string rotate_90_degrees_ccw: "\ue418" readonly property string rotate_90_degrees_cw: "\ueaab" readonly property string rotate_left: "\ue419" readonly property string rotate_right: "\ue41a" readonly property string roundabout_left: "\ueb99" readonly property string roundabout_right: "\ueba3" readonly property string rounded_corner: "\ue920" readonly property string route: "\ueacd" readonly property string router: "\ue328" readonly property string rowing: "\ue921" readonly property string rss_feed: "\ue0e5" readonly property string rsvp: "\uf055" readonly property string rtt: "\ue9ad" readonly property string rule: "\uf1c2" readonly property string rule_folder: "\uf1c9" readonly property string run_circle: "\uef6f" readonly property string running_with_errors: "\ue51d" readonly property string rv_hookup: "\ue642" readonly property string safety_check: "\uebef" readonly property string safety_divider: "\ue1cc" readonly property string sailing: "\ue502" readonly property string sanitizer: "\uf21d" readonly property string satellite: "\ue562" readonly property string satellite_alt: "\ueb3a" readonly property string save: "\ue161" readonly property string save_alt: "\ue171" readonly property string save_as: "\ueb60" readonly property string saved_search: "\uea11" readonly property string savings: "\ue2eb" readonly property string scale: "\ueb5f" readonly property string scanner: "\ue329" readonly property string scatter_plot: "\ue268" readonly property string schedule: "\ue8b5" readonly property string schedule_send: "\uea0a" readonly property string schema: "\ue4fd" readonly property string school: "\ue80c" readonly property string science: "\uea4b" readonly property string score: "\ue269" readonly property string scoreboard: "\uebd0" readonly property string screen_lock_landscape: "\ue1be" readonly property string screen_lock_portrait: "\ue1bf" readonly property string screen_lock_rotation: "\ue1c0" readonly property string screen_rotation: "\ue1c1" readonly property string screen_rotation_alt: "\uebee" readonly property string screen_search_desktop: "\uef70" readonly property string screen_share: "\ue0e2" readonly property string screenshot: "\uf056" readonly property string screenshot_monitor: "\uec08" readonly property string scuba_diving: "\uebce" readonly property string sd: "\ue9dd" readonly property string sd_card: "\ue623" readonly property string sd_card_alert: "\uf057" readonly property string sd_storage: "\ue1c2" readonly property string search: "\ue8b6" readonly property string search_off: "\uea76" readonly property string security: "\ue32a" readonly property string security_update: "\uf058" readonly property string security_update_good: "\uf059" readonly property string security_update_warning: "\uf05a" readonly property string segment: "\ue94b" readonly property string select_all: "\ue162" readonly property string self_improvement: "\uea78" readonly property string sell: "\uf05b" readonly property string send: "\ue163" readonly property string send_and_archive: "\uea0c" readonly property string send_time_extension: "\ueadb" readonly property string send_to_mobile: "\uf05c" readonly property string sensor_door: "\uf1b5" readonly property string sensor_occupied: "\uec10" readonly property string sensor_window: "\uf1b4" readonly property string sensors: "\ue51e" readonly property string sensors_off: "\ue51f" readonly property string sentiment_dissatisfied: "\ue811" readonly property string sentiment_neutral: "\ue812" readonly property string sentiment_satisfied: "\ue813" readonly property string sentiment_satisfied_alt: "\ue0ed" readonly property string sentiment_very_dissatisfied: "\ue814" readonly property string sentiment_very_satisfied: "\ue815" readonly property string set_meal: "\uf1ea" readonly property string settings: "\ue8b8" readonly property string settings_accessibility: "\uf05d" readonly property string settings_applications: "\ue8b9" readonly property string settings_backup_restore: "\ue8ba" readonly property string settings_bluetooth: "\ue8bb" readonly property string settings_brightness: "\ue8bd" readonly property string settings_cell: "\ue8bc" readonly property string settings_display: "\ue8bd" readonly property string settings_ethernet: "\ue8be" readonly property string settings_input_antenna: "\ue8bf" readonly property string settings_input_component: "\ue8c0" readonly property string settings_input_composite: "\ue8c1" readonly property string settings_input_hdmi: "\ue8c2" readonly property string settings_input_svideo: "\ue8c3" readonly property string settings_overscan: "\ue8c4" readonly property string settings_phone: "\ue8c5" readonly property string settings_power: "\ue8c6" readonly property string settings_remote: "\ue8c7" readonly property string settings_suggest: "\uf05e" readonly property string settings_system_daydream: "\ue1c3" readonly property string settings_voice: "\ue8c8" readonly property string severe_cold: "\uebd3" readonly property string shape_line: "\uf8d3" readonly property string share: "\ue80d" readonly property string share_arrival_time: "\ue524" readonly property string share_location: "\uf05f" readonly property string shelves: "\uf86e" readonly property string shield: "\ue9e0" readonly property string shield_moon: "\ueaa9" readonly property string shop: "\ue8c9" readonly property string shop_2: "\ue19e" readonly property string shop_two: "\ue8ca" readonly property string shopify: "\uea9d" readonly property string shopping_bag: "\uf1cc" readonly property string shopping_basket: "\ue8cb" readonly property string shopping_cart: "\ue8cc" readonly property string shopping_cart_checkout: "\ueb88" readonly property string short_text: "\ue261" readonly property string shortcut: "\uf060" readonly property string show_chart: "\ue6e1" readonly property string shower: "\uf061" readonly property string shuffle: "\ue043" readonly property string shuffle_on: "\ue9e1" readonly property string shutter_speed: "\ue43d" readonly property string sick: "\uf220" readonly property string sign_language: "\uebe5" readonly property string signal_cellular_0_bar: "\uf0a8" readonly property string signal_cellular_4_bar: "\ue1c8" readonly property string signal_cellular_alt: "\ue202" readonly property string signal_cellular_alt_1_bar: "\uebdf" readonly property string signal_cellular_alt_2_bar: "\uebe3" readonly property string signal_cellular_connected_no_internet_0_bar: "\uf0ac" readonly property string signal_cellular_connected_no_internet_4_bar: "\ue1cd" readonly property string signal_cellular_no_sim: "\ue1ce" readonly property string signal_cellular_nodata: "\uf062" readonly property string signal_cellular_null: "\ue1cf" readonly property string signal_cellular_off: "\ue1d0" readonly property string signal_wifi_0_bar: "\uf0b0" readonly property string signal_wifi_4_bar: "\ue1d8" readonly property string signal_wifi_4_bar_lock: "\ue1d9" readonly property string signal_wifi_bad: "\uf063" readonly property string signal_wifi_connected_no_internet_4: "\uf064" readonly property string signal_wifi_off: "\ue1da" readonly property string signal_wifi_statusbar_4_bar: "\uf065" readonly property string signal_wifi_statusbar_connected_no_internet_4: "\uf066" readonly property string signal_wifi_statusbar_null: "\uf067" readonly property string signpost: "\ueb91" readonly property string sim_card: "\ue32b" readonly property string sim_card_alert: "\ue624" readonly property string sim_card_download: "\uf068" readonly property string single_bed: "\uea48" readonly property string sip: "\uf069" readonly property string skateboarding: "\ue511" readonly property string skip_next: "\ue044" readonly property string skip_previous: "\ue045" readonly property string sledding: "\ue512" readonly property string slideshow: "\ue41b" readonly property string slow_motion_video: "\ue068" readonly property string smart_button: "\uf1c1" readonly property string smart_display: "\uf06a" readonly property string smart_screen: "\uf06b" readonly property string smart_toy: "\uf06c" readonly property string smartphone: "\ue32c" readonly property string smoke_free: "\ueb4a" readonly property string smoking_rooms: "\ueb4b" readonly property string sms: "\ue625" readonly property string sms_failed: "\ue626" readonly property string snapchat: "\uea6e" readonly property string snippet_folder: "\uf1c7" readonly property string snooze: "\ue046" readonly property string snowboarding: "\ue513" readonly property string snowing: "\ue80f" readonly property string snowmobile: "\ue503" readonly property string snowshoeing: "\ue514" readonly property string soap: "\uf1b2" readonly property string social_distance: "\ue1cb" readonly property string solar_power: "\uec0f" readonly property string sort: "\ue164" readonly property string sort_by_alpha: "\ue053" readonly property string sos: "\uebf7" readonly property string soup_kitchen: "\ue7d3" readonly property string source: "\uf1c4" readonly property string south: "\uf1e3" readonly property string south_america: "\ue7e4" readonly property string south_east: "\uf1e4" readonly property string south_west: "\uf1e5" readonly property string spa: "\ueb4c" readonly property string space_bar: "\ue256" readonly property string space_dashboard: "\ue66b" readonly property string spatial_audio: "\uebeb" readonly property string spatial_audio_off: "\uebe8" readonly property string spatial_tracking: "\uebea" readonly property string speaker: "\ue32d" readonly property string speaker_group: "\ue32e" readonly property string speaker_notes: "\ue8cd" readonly property string speaker_notes_off: "\ue92a" readonly property string speaker_phone: "\ue0d2" readonly property string speed: "\ue9e4" readonly property string spellcheck: "\ue8ce" readonly property string splitscreen: "\uf06d" readonly property string spoke: "\ue9a7" readonly property string sports: "\uea30" readonly property string sports_bar: "\uf1f3" readonly property string sports_baseball: "\uea51" readonly property string sports_basketball: "\uea26" readonly property string sports_cricket: "\uea27" readonly property string sports_esports: "\uea28" readonly property string sports_football: "\uea29" readonly property string sports_golf: "\uea2a" readonly property string sports_gymnastics: "\uebc4" readonly property string sports_handball: "\uea33" readonly property string sports_hockey: "\uea2b" readonly property string sports_kabaddi: "\uea34" readonly property string sports_martial_arts: "\ueae9" readonly property string sports_mma: "\uea2c" readonly property string sports_motorsports: "\uea2d" readonly property string sports_rugby: "\uea2e" readonly property string sports_score: "\uf06e" readonly property string sports_soccer: "\uea2f" readonly property string sports_tennis: "\uea32" readonly property string sports_volleyball: "\uea31" readonly property string square: "\ueb36" readonly property string square_foot: "\uea49" readonly property string ssid_chart: "\ueb66" readonly property string stacked_bar_chart: "\ue9e6" readonly property string stacked_line_chart: "\uf22b" readonly property string stadium: "\ueb90" readonly property string stairs: "\uf1a9" readonly property string star: "\ue838" readonly property string star_border: "\ue83a" readonly property string star_border_purple500: "\uf099" readonly property string star_half: "\ue839" readonly property string star_outline: "\uf06f" readonly property string star_purple500: "\uf09a" readonly property string star_rate: "\uf0ec" readonly property string stars: "\ue8d0" readonly property string start: "\ue089" readonly property string stay_current_landscape: "\ue0d3" readonly property string stay_current_portrait: "\ue0d4" readonly property string stay_primary_landscape: "\ue0d5" readonly property string stay_primary_portrait: "\ue0d6" readonly property string sticky_note_2: "\uf1fc" readonly property string stop: "\ue047" readonly property string stop_circle: "\uef71" readonly property string stop_screen_share: "\ue0e3" readonly property string storage: "\ue1db" readonly property string store: "\ue8d1" readonly property string store_mall_directory: "\ue563" readonly property string storefront: "\uea12" readonly property string storm: "\uf070" readonly property string straight: "\ueb95" readonly property string straighten: "\ue41c" readonly property string stream: "\ue9e9" readonly property string streetview: "\ue56e" readonly property string strikethrough_s: "\ue257" readonly property string stroller: "\uf1ae" readonly property string style: "\ue41d" readonly property string subdirectory_arrow_left: "\ue5d9" readonly property string subdirectory_arrow_right: "\ue5da" readonly property string subject: "\ue8d2" readonly property string subscript: "\uf111" readonly property string subscriptions: "\ue064" readonly property string subtitles: "\ue048" readonly property string subtitles_off: "\uef72" readonly property string subway: "\ue56f" readonly property string summarize: "\uf071" readonly property string sunny: "\ue81a" readonly property string sunny_snowing: "\ue819" readonly property string superscript: "\uf112" readonly property string supervised_user_circle: "\ue939" readonly property string supervisor_account: "\ue8d3" readonly property string support: "\uef73" readonly property string support_agent: "\uf0e2" readonly property string surfing: "\ue515" readonly property string surround_sound: "\ue049" readonly property string swap_calls: "\ue0d7" readonly property string swap_horiz: "\ue8d4" readonly property string swap_horizontal_circle: "\ue933" readonly property string swap_vert: "\ue8d5" readonly property string swap_vert_circle: "\ue8d6" readonly property string swap_vertical_circle: "\ue8d6" readonly property string swipe: "\ue9ec" readonly property string swipe_down: "\ueb53" readonly property string swipe_down_alt: "\ueb30" readonly property string swipe_left: "\ueb59" readonly property string swipe_left_alt: "\ueb33" readonly property string swipe_right: "\ueb52" readonly property string swipe_right_alt: "\ueb56" readonly property string swipe_up: "\ueb2e" readonly property string swipe_up_alt: "\ueb35" readonly property string swipe_vertical: "\ueb51" readonly property string switch_access_shortcut: "\ue7e1" readonly property string switch_access_shortcut_add: "\ue7e2" readonly property string switch_account: "\ue9ed" readonly property string switch_camera: "\ue41e" readonly property string switch_left: "\uf1d1" readonly property string switch_right: "\uf1d2" readonly property string switch_video: "\ue41f" readonly property string synagogue: "\ueab0" readonly property string sync: "\ue627" readonly property string sync_alt: "\uea18" readonly property string sync_disabled: "\ue628" readonly property string sync_lock: "\ueaee" readonly property string sync_problem: "\ue629" readonly property string system_security_update: "\uf072" readonly property string system_security_update_good: "\uf073" readonly property string system_security_update_warning: "\uf074" readonly property string system_update: "\ue62a" readonly property string system_update_alt: "\ue8d7" readonly property string system_update_tv: "\ue8d7" readonly property string tab: "\ue8d8" readonly property string tab_unselected: "\ue8d9" readonly property string table_bar: "\uead2" readonly property string table_chart: "\ue265" readonly property string table_restaurant: "\ueac6" readonly property string table_rows: "\uf101" readonly property string table_view: "\uf1be" readonly property string tablet: "\ue32f" readonly property string tablet_android: "\ue330" readonly property string tablet_mac: "\ue331" readonly property string tag: "\ue9ef" readonly property string tag_faces: "\ue420" readonly property string takeout_dining: "\uea74" readonly property string tap_and_play: "\ue62b" readonly property string tapas: "\uf1e9" readonly property string task: "\uf075" readonly property string task_alt: "\ue2e6" readonly property string taxi_alert: "\uef74" readonly property string telegram: "\uea6b" readonly property string temple_buddhist: "\ueab3" readonly property string temple_hindu: "\ueaaf" readonly property string terminal: "\ueb8e" readonly property string terrain: "\ue564" readonly property string text_decrease: "\ueadd" readonly property string text_fields: "\ue262" readonly property string text_format: "\ue165" readonly property string text_increase: "\ueae2" readonly property string text_rotate_up: "\ue93a" readonly property string text_rotate_vertical: "\ue93b" readonly property string text_rotation_angledown: "\ue93c" readonly property string text_rotation_angleup: "\ue93d" readonly property string text_rotation_down: "\ue93e" readonly property string text_rotation_none: "\ue93f" readonly property string text_snippet: "\uf1c6" readonly property string textsms: "\ue0d8" readonly property string texture: "\ue421" readonly property string theater_comedy: "\uea66" readonly property string theaters: "\ue8da" readonly property string thermostat: "\uf076" readonly property string thermostat_auto: "\uf077" readonly property string thumb_down: "\ue8db" readonly property string thumb_down_alt: "\ue816" readonly property string thumb_down_off_alt: "\ue9f2" readonly property string thumb_up: "\ue8dc" readonly property string thumb_up_alt: "\ue817" readonly property string thumb_up_off_alt: "\ue9f3" readonly property string thumbs_up_down: "\ue8dd" readonly property string thunderstorm: "\uebdb" readonly property string tiktok: "\uea7e" readonly property string time_to_leave: "\ue62c" readonly property string timelapse: "\ue422" readonly property string timeline: "\ue922" readonly property string timer: "\ue425" readonly property string timer_10: "\ue423" readonly property string timer_10_select: "\uf07a" readonly property string timer_3: "\ue424" readonly property string timer_3_select: "\uf07b" readonly property string timer_off: "\ue426" readonly property string tips_and_updates: "\ue79a" readonly property string tire_repair: "\uebc8" readonly property string title: "\ue264" readonly property string toc: "\ue8de" readonly property string today: "\ue8df" readonly property string toggle_off: "\ue9f5" readonly property string toggle_on: "\ue9f6" readonly property string token: "\uea25" readonly property string toll: "\ue8e0" readonly property string tonality: "\ue427" readonly property string topic: "\uf1c8" readonly property string tornado: "\ue199" readonly property string touch_app: "\ue913" readonly property string tour: "\uef75" readonly property string toys: "\ue332" readonly property string track_changes: "\ue8e1" readonly property string traffic: "\ue565" readonly property string train: "\ue570" readonly property string tram: "\ue571" readonly property string transcribe: "\uf8ec" readonly property string transfer_within_a_station: "\ue572" readonly property string transform_: "\ue428" readonly property string transgender: "\ue58d" readonly property string transit_enterexit: "\ue579" readonly property string translate: "\ue8e2" readonly property string travel_explore: "\ue2db" readonly property string trending_down: "\ue8e3" readonly property string trending_flat: "\ue8e4" readonly property string trending_neutral: "\ue8e4" readonly property string trending_up: "\ue8e5" readonly property string trip_origin: "\ue57b" readonly property string trolley: "\uf86b" readonly property string troubleshoot: "\ue1d2" readonly property string try_: "\uf07c" readonly property string tsunami: "\uebd8" readonly property string tty: "\uf1aa" readonly property string tune: "\ue429" readonly property string tungsten: "\uf07d" readonly property string turn_left: "\ueba6" readonly property string turn_right: "\uebab" readonly property string turn_sharp_left: "\ueba7" readonly property string turn_sharp_right: "\uebaa" readonly property string turn_slight_left: "\ueba4" readonly property string turn_slight_right: "\ueb9a" readonly property string turned_in: "\ue8e6" readonly property string turned_in_not: "\ue8e7" readonly property string tv: "\ue333" readonly property string tv_off: "\ue647" readonly property string two_wheeler: "\ue9f9" readonly property string type_specimen: "\uf8f0" readonly property string u_turn_left: "\ueba1" readonly property string u_turn_right: "\ueba2" readonly property string umbrella: "\uf1ad" readonly property string unarchive: "\ue169" readonly property string undo: "\ue166" readonly property string unfold_less: "\ue5d6" readonly property string unfold_less_double: "\uf8cf" readonly property string unfold_more: "\ue5d7" readonly property string unfold_more_double: "\uf8d0" readonly property string unpublished: "\uf236" readonly property string unsubscribe: "\ue0eb" readonly property string upcoming: "\uf07e" readonly property string update: "\ue923" readonly property string update_disabled: "\ue075" readonly property string upgrade: "\uf0fb" readonly property string upload: "\uf09b" readonly property string upload_file: "\ue9fc" readonly property string usb: "\ue1e0" readonly property string usb_off: "\ue4fa" readonly property string vaccines: "\ue138" readonly property string vape_free: "\uebc6" readonly property string vaping_rooms: "\uebcf" readonly property string verified: "\uef76" readonly property string verified_user: "\ue8e8" readonly property string vertical_align_bottom: "\ue258" readonly property string vertical_align_center: "\ue259" readonly property string vertical_align_top: "\ue25a" readonly property string vertical_distribute: "\ue076" readonly property string vertical_shades: "\uec0e" readonly property string vertical_shades_closed: "\uec0d" readonly property string vertical_split: "\ue949" readonly property string vibration: "\ue62d" readonly property string video_call: "\ue070" readonly property string video_camera_back: "\uf07f" readonly property string video_camera_front: "\uf080" readonly property string video_chat: "\uf8a0" readonly property string video_collection: "\ue04a" readonly property string video_file: "\ueb87" readonly property string video_label: "\ue071" readonly property string video_library: "\ue04a" readonly property string video_settings: "\uea75" readonly property string video_stable: "\uf081" readonly property string videocam: "\ue04b" readonly property string videocam_off: "\ue04c" readonly property string videogame_asset: "\ue338" readonly property string videogame_asset_off: "\ue500" readonly property string view_agenda: "\ue8e9" readonly property string view_array: "\ue8ea" readonly property string view_carousel: "\ue8eb" readonly property string view_column: "\ue8ec" readonly property string view_comfortable: "\ue42a" readonly property string view_comfy: "\ue42a" readonly property string view_comfy_alt: "\ueb73" readonly property string view_compact: "\ue42b" readonly property string view_compact_alt: "\ueb74" readonly property string view_cozy: "\ueb75" readonly property string view_day: "\ue8ed" readonly property string view_headline: "\ue8ee" readonly property string view_in_ar: "\ue9fe" readonly property string view_kanban: "\ueb7f" readonly property string view_list: "\ue8ef" readonly property string view_module: "\ue8f0" readonly property string view_quilt: "\ue8f1" readonly property string view_sidebar: "\uf114" readonly property string view_stream: "\ue8f2" readonly property string view_timeline: "\ueb85" readonly property string view_week: "\ue8f3" readonly property string vignette: "\ue435" readonly property string villa: "\ue586" readonly property string visibility: "\ue8f4" readonly property string visibility_off: "\ue8f5" readonly property string voice_chat: "\ue62e" readonly property string voice_over_off: "\ue94a" readonly property string voicemail: "\ue0d9" readonly property string volcano: "\uebda" readonly property string volume_down: "\ue04d" readonly property string volume_down_alt: "\ue79c" readonly property string volume_mute: "\ue04e" readonly property string volume_off: "\ue04f" readonly property string volume_up: "\ue050" readonly property string volunteer_activism: "\uea70" readonly property string vpn_key: "\ue0da" readonly property string vpn_key_off: "\ueb7a" readonly property string vpn_lock: "\ue62f" readonly property string vrpano: "\uf082" readonly property string wallet: "\uf8ff" readonly property string wallet_giftcard: "\ue8f6" readonly property string wallet_membership: "\ue8f7" readonly property string wallet_travel: "\ue8f8" readonly property string wallpaper: "\ue1bc" readonly property string warehouse: "\uebb8" readonly property string warning: "\ue002" readonly property string warning_amber: "\uf083" readonly property string wash: "\uf1b1" readonly property string watch: "\ue334" readonly property string watch_later: "\ue924" readonly property string watch_off: "\ueae3" readonly property string water: "\uf084" readonly property string water_damage: "\uf203" readonly property string water_drop: "\ue798" readonly property string waterfall_chart: "\uea00" readonly property string waves: "\ue176" readonly property string waving_hand: "\ue766" readonly property string wb_auto: "\ue42c" readonly property string wb_cloudy: "\ue42d" readonly property string wb_incandescent: "\ue42e" readonly property string wb_iridescent: "\ue436" readonly property string wb_shade: "\uea01" readonly property string wb_sunny: "\ue430" readonly property string wb_twighlight: "\uea02" readonly property string wb_twilight: "\ue1c6" readonly property string wc: "\ue63d" readonly property string web: "\ue051" readonly property string web_asset: "\ue069" readonly property string web_asset_off: "\ue4f7" readonly property string web_stories: "\ue595" readonly property string webhook: "\ueb92" readonly property string wechat: "\uea81" readonly property string weekend: "\ue16b" readonly property string west: "\uf1e6" readonly property string whatshot: "\ue80e" readonly property string wheelchair_pickup: "\uf1ab" readonly property string where_to_vote: "\ue177" readonly property string widgets: "\ue1bd" readonly property string width_full: "\uf8f5" readonly property string width_normal: "\uf8f6" readonly property string width_wide: "\uf8f7" readonly property string wifi: "\ue63e" readonly property string wifi_1_bar: "\ue4ca" readonly property string wifi_2_bar: "\ue4d9" readonly property string wifi_calling: "\uef77" readonly property string wifi_calling_3: "\uf085" readonly property string wifi_channel: "\ueb6a" readonly property string wifi_find: "\ueb31" readonly property string wifi_lock: "\ue1e1" readonly property string wifi_off: "\ue648" readonly property string wifi_pwd: "\ueb6b" readonly property string wifi_protected_setup: "\uf0fc" readonly property string wifi_tethering: "\ue1e2" readonly property string wifi_tethering_error: "\uead9" readonly property string wifi_tethering_error_rounded: "\uf086" readonly property string wifi_tethering_off: "\uf087" readonly property string wind_power: "\uec0c" readonly property string window: "\uf088" readonly property string wine_bar: "\uf1e8" readonly property string woman: "\ue13e" readonly property string woman_2: "\uf8e7" readonly property string woo_commerce: "\uea6d" readonly property string wordpress: "\uea9f" readonly property string work: "\ue8f9" readonly property string work_history: "\uec09" readonly property string work_off: "\ue942" readonly property string work_outline: "\ue943" readonly property string workspace_premium: "\ue7af" readonly property string workspaces: "\ue1a0" readonly property string workspaces_filled: "\uea0d" readonly property string workspaces_outline: "\uea0f" readonly property string wrap_text: "\ue25b" readonly property string wrong_location: "\uef78" readonly property string wysiwyg: "\uf1c3" readonly property string yard: "\uf089" readonly property string youtube_searched_for: "\ue8fa" readonly property string zoom_in: "\ue8ff" readonly property string zoom_in_map: "\ueb2d" readonly property string zoom_out: "\ue900" readonly property string zoom_out_map: "\ue56b" } ================================================ FILE: meshroom/ui/qml/MaterialIcons/MaterialLabel.qml ================================================ import QtQuick import QtQuick.Controls /** * MaterialLabel is a standard Label using MaterialIcons font. * If ToolTip.text is set, it also shows up a tooltip when hovered. */ Label { font.family: MaterialIcons.fontFamily font.pointSize: 10 ToolTip.visible: toolTipLoader.active && toolTipLoader.item.containsMouse ToolTip.delay: 1000 Loader { id: toolTipLoader anchors.fill: parent active: parent.ToolTip.text sourceComponent: MouseArea { hoverEnabled: true acceptedButtons: Qt.NoButton } } } ================================================ FILE: meshroom/ui/qml/MaterialIcons/MaterialToolButton.qml ================================================ import QtQuick import QtQuick.Controls import QtQuick.Layouts /** * MaterialToolButton is a standard ToolButton using MaterialIcons font. * It also shows up its tooltip when hovered. */ ToolButton { id: control font.family: MaterialIcons.fontFamily padding: 4 font.pointSize: 13 ToolTip.visible: ToolTip.text && hovered ToolTip.delay: 100 property color textColor: checked ? palette.highlight : palette.text Component.onCompleted: { contentItem.color = Qt.binding(function() { return textColor }) } background: Rectangle { color: { if (enabled && (pressed || checked || hovered)) { if (pressed || checked) return Qt.darker(parent.palette.base, 1.3) if (hovered) return Qt.darker(parent.palette.base, 0.6) } return "transparent" } border.color: checked ? Qt.darker(parent.palette.base, 1.4) : "transparent" } } ================================================ FILE: meshroom/ui/qml/MaterialIcons/MaterialToolLabel.qml ================================================ import QtQuick import QtQuick.Controls import QtQuick.Layouts /** * MaterialToolLabel is a Label with an icon (using MaterialIcons). * It shows up its tooltip when hovered. */ Item { id: control property alias icon: iconItem property alias iconText: iconItem.text property alias iconSize: iconItem.font.pointSize property alias label: labelItem property alias labelIconRow: contentRow property alias labelIconMouseArea: mouseArea property var labelIconColor: palette.text implicitWidth: childrenRect.width implicitHeight: childrenRect.height RowLayout { id: contentRow // If we are fitting to a top container, we need to propagate the "anchors.fill: parent". // Otherwise, the component defines its own size based on its children. anchors.fill: control.anchors.fill ? parent : undefined Label { id: iconItem font.family: MaterialIcons.fontFamily font.pointSize: 13 padding: 0 text: "" color: control.labelIconColor Layout.fillWidth: false Layout.fillHeight: true } Label { id: labelItem text: "" color: control.labelIconColor Layout.fillWidth: true Layout.fillHeight: true } } MouseArea { id: mouseArea anchors.fill: parent hoverEnabled: true acceptedButtons: Qt.NoButton } ToolTip.visible: mouseArea.containsMouse ToolTip.delay: 500 } ================================================ FILE: meshroom/ui/qml/MaterialIcons/MaterialToolLabelButton.qml ================================================ import QtQuick import QtQuick.Controls import QtQuick.Layouts /** * MaterialToolButton is a standard ToolButton using MaterialIcons font. * It also shows up its tooltip when hovered. */ ToolButton { id: control property alias iconText: icon.text property alias iconSize: icon.font.pointSize property alias label: labelItem.text padding: 0 ToolTip.visible: ToolTip.text && hovered ToolTip.delay: 100 property alias labelItem: labelItem property alias iconItem: icon property alias rowIconLabel: rowIconLabel contentItem: RowLayout { id: rowIconLabel Layout.margins: 0 Label { id: icon font.family: MaterialIcons.fontFamily font.pointSize: 13 padding: 0 text: "" color: (checked ? palette.highlight : palette.text) } Label { id: labelItem text: "" padding: 0 color: (checked ? palette.highlight : palette.text) Layout.fillWidth: true } } background: Rectangle { color: { if (enabled && (pressed || checked || hovered)) { if (pressed || checked) return Qt.darker(parent.palette.base, 1.3) if (hovered) return Qt.darker(parent.palette.base, 0.6) } return "transparent" } border.color: checked ? Qt.darker(parent.palette.base, 1.4) : "transparent" } } ================================================ FILE: meshroom/ui/qml/MaterialIcons/generate_material_icons.py ================================================ import argparse import os parser = argparse.ArgumentParser(description='Generate a MaterialIcons.qml singleton from codepoints file.\n' 'An example of codepoints file for MaterialIcons: https://github.com/google/material-design-icons/blob/master/font/MaterialIcons-Regular.codepoints.') parser.add_argument('codepoints', type=str, help='Codepoints file.') parser.add_argument('--output', type=str, default='.', help='') args = parser.parse_args() # Override icons with problematic names mapping = { 'delete': 'delete_', 'class': 'class_', '3d_rotation': '_3d_rotation', 'opacity': 'opacity_', 'transform': 'transform_', 'print': 'print_', 'public': 'public_', 'password': 'pwd', 'wifi_password': 'wifi_pwd', 'try': 'try_' } # Override icons that are numeric literals numeric_literals = ['1', '2', '3', '4', '5', '6', '7', '8', '9'] # List of existing name to override potential duplicates names = [] with open(os.path.join(args.output, 'MaterialIcons.qml'), 'w') as qml_file: qml_file.write('pragma Singleton\n') qml_file.write('import QtQuick 2.15\n\n') qml_file.write('QtObject {\n') qml_file.write(' property FontLoader fl: FontLoader {\n') qml_file.write(' source: "./MaterialIcons-Regular.ttf"\n') qml_file.write(' }\n') qml_file.write(' readonly property string fontFamily: fl.name\n\n') with open(args.codepoints, 'r') as codepoints: for line in codepoints.readlines(): name, code = line.strip().split(" ") name = mapping.get(name, name) # Add underscore to names that are numeric literals (e.g. "123" will become "_123") if name[0] in numeric_literals: name = "_" + name # If the name already exists in the list, append an index if name in names: index = 2 while name + str(index) in names: index = index + 1 name = name + str(index) names.append(name) qml_file.write(f' readonly property string {name}: "\\u{code}\"\n') qml_file.write('}\n') ================================================ FILE: meshroom/ui/qml/MaterialIcons/qmldir ================================================ module MaterialIcons singleton MaterialIcons 2.2 MaterialIcons.qml MaterialToolButton 2.2 MaterialToolButton.qml MaterialToolLabelButton 2.2 MaterialToolLabelButton.qml MaterialToolLabel 2.2 MaterialToolLabel.qml MaterialLabel 2.2 MaterialLabel.qml MLabel 2.2 MLabel.qml ================================================ FILE: meshroom/ui/qml/Shapes/Editor/Items/ShapeAttributeItem.qml ================================================ import QtQuick import QtQuick.Controls import "Utils" as ItemUtils /** * ShapeAttributeItem * * @biref ShapeAttribute component for the ShapeEditor. * @param shapeAttribute - the given ShapeAttribute model * @param isNeasted - whether the item is neasted */ Column { id: shapeAttributeItem width: parent.width spacing: 0 // Properties property var shapeAttribute property alias isNeasted: itemHeader.isNeasted property alias isLinkChild: itemHeader.isLinkChild // Item Header ItemUtils.ItemHeader { id: itemHeader model: shapeAttribute isShape: true isAttribute: true } // Perhaps add an expandable list for current observations later } ================================================ FILE: meshroom/ui/qml/Shapes/Editor/Items/ShapeDataItem.qml ================================================ import QtQuick import QtQuick.Controls import "Utils" as ItemUtils /** * ShapeDataItem * * @biref ShapeData component for the ShapeEditor. * @param shapeData - the given ShapeData model * @param isNeasted - whether the item is neasted */ Column { id: shapeDataItem width: parent.width spacing: 0 // Properties property var shapeData property alias isNeasted: itemHeader.isNeasted // Item Header ItemUtils.ItemHeader { id: itemHeader model: shapeData isShape: true isAttribute: false } // Perhaps add an expandable list for current observations later } ================================================ FILE: meshroom/ui/qml/Shapes/Editor/Items/ShapeFileItem.qml ================================================ import QtQuick import QtQuick.Controls import "Utils" as ItemUtils /** * ShapeFileItem * * @biref ShapeFile component for the ShapeEditor. * @param shapeFile - the given ShapeFile model */ Column { id: shapeFileItem width: parent.width spacing: 0 // Properties property var shapeFile // Item Header ItemUtils.ItemHeader { id: itemHeader model: shapeFile isShape: false isAttribute: false } // Expandable list Loader { active: itemHeader.isExpanded width: parent.width height: active ? (item ? item.implicitHeight || item.height : 0) : 0 sourceComponent: Pane { background: Rectangle { color: "transparent" } padding: 0 implicitWidth: parent.width implicitHeight: subList.contentHeight ListView { id: subList anchors.fill: parent spacing: 2 interactive: false model: shapeFile.shapes delegate: ShapeDataItem { shapeData: object isNeasted: true width: ListView.view.width height: implicitHeight } } } } } ================================================ FILE: meshroom/ui/qml/Shapes/Editor/Items/ShapeListAttributeItem.qml ================================================ import QtQuick import QtQuick.Controls import "Utils" as ItemUtils /** * ShapeListAttributeItem * * @biref ShapeListAttribute component for the ShapeEditor. * @param shapeListAttribute - the given ShapeListAttribute model */ Column { id: shapeListAttributeItem width: parent.width spacing: 0 // Properties property var shapeListAttribute // Item Header ItemUtils.ItemHeader { id: itemHeader model: shapeListAttribute isShape: false isAttribute: true } // Expandable list Loader { active: itemHeader.isExpanded width: parent.width height: active ? (item ? item.implicitHeight || item.height : 0) : 0 sourceComponent: Pane { background: Rectangle { color: "transparent" } padding: 0 implicitWidth: parent.width implicitHeight: subList.contentHeight ListView { id: subList anchors.fill: parent spacing: 2 interactive: false model: shapeListAttribute.value delegate: ShapeAttributeItem { shapeAttribute: object isNeasted: true isLinkChild: shapeListAttribute.isLink width: ListView.view.width height: implicitHeight } } } } } ================================================ FILE: meshroom/ui/qml/Shapes/Editor/Items/Utils/ItemHeader.qml ================================================ import QtQuick import QtQuick.Controls import QtQuick.Layouts import QtQuick.Dialogs import MaterialIcons 2.2 import Controls 1.0 import Utils 1.0 /** * ItemHeader * * @biref Item header component for the ShapeEditor. * @param model - the given model (provide by the current node or ShapeFilesHelper) * @param isShape - whether the model is a shape (ShapeAttribute or ShapeData) * @param isAttribute - whether the model is an attribute (ShapeAttribute or ShapeListAttribute) * @param isNeasted - whether the header is neasted * @param isLinkChild - Whether the model is a child attribute of a linked attribute * @param isExpanded - whether the heder is expanded */ Pane { id: itemHeader width: parent.width // Model properties property var model property bool isShape: false property bool isAttribute: false property bool isLinkChild: false // Header properties property bool isNeasted: false property bool isExpanded: false // Read-only properties readonly property bool isAttributeSelected: isAttribute ? (ShapeViewerHelper.selectedShapeName === model.fullName) : false readonly property bool isAttributeInitialized: isAttribute ? (isShape ? !model.geometry.isDefault : !model.isDefault) : false readonly property bool isAttributeEnabled: isAttribute ? (model.enabled && !model.isLink && !isLinkChild) : false // Padding topPadding: 2 bottomPadding: 2 rightPadding: 6 leftPadding: 6 // Background background: Rectangle { radius: 3 border.color: palette.highlight border.width: { if(isAttributeSelected) return 2 return 0 } color: { if(isAttributeSelected) return palette.window if(hoverHandler.hovered) return Qt.darker(palette.window, 1.1) return "transparent" } SequentialAnimation { id: flickAnimation loops: 2 NumberAnimation { target: itemHeader.background property: "border.width" to: 1 duration: 100 } NumberAnimation { target: itemHeader.background property: "border.width" to: 0 duration: 100 } PauseAnimation { duration: 50 } } } // Item header menu // Popup on right mouse button Menu { id: itemHeaderMenu MenuItem { text: "Reset" enabled: isAttributeEnabled && isAttributeInitialized onTriggered: { _currentScene.resetAttribute(model) ShapeViewerHelper.selectedShapeName = "" isExpanded = false } } } // Hover Handle HoverHandler { id: hoverHandler margin: 3 } // Tap Handler // Left and Right mouse button handler TapHandler { acceptedButtons: Qt.LeftButton | Qt.RightButton gesturePolicy: TapHandler.WithinBounds margin: 3 onTapped: function(eventPoint, button) { // Right mouse button if(button === Qt.RightButton) itemHeaderMenu.popup() // Left mouse button if(button === Qt.LeftButton && isShape && isAttributeEnabled && isAttributeInitialized) { // Single tap if(tapCount === 1 && model.isVisible) { ShapeViewerHelper.selectedShapeName = model.fullName } // Double tap if(tapCount === 2 && !model.isVisible) { ShapeViewerHelper.selectedShapeName = model.fullName model.isVisible = true } } else { flickAnimation.start() } } } // MaterialIcons font metrics FontMetrics { id: materialMetrics font.family: MaterialIcons.fontFamily font.pointSize: 11 } // Row layout RowLayout { anchors.fill: parent anchors.rightMargin: 2 spacing: 0 // Shape visibility MaterialToolButton { font.pointSize: 9 padding: (materialMetrics.height / 11.0) + 2 text: model.isVisible ? MaterialIcons.visibility : MaterialIcons.visibility_off opacity: model.isVisible ? 1.0 : 0.5 enabled: true onClicked: { model.isVisible = !model.isVisible } ToolTip.text: model.isVisible ? "Visible" : "Hidden" ToolTip.visible: hovered ToolTip.delay: 800 } // Neasted spacer // 1x icon + 2x padding Item { visible: isNeasted width: materialMetrics.height + 4 } // Shape attributes dropdown // For now, only for ShapeFile and ShapeListAttribute Loader { active: !isShape sourceComponent: MaterialToolButton { font.pointSize: 11 padding: 2 text: { if(isExpanded) { return (isShape) ? MaterialIcons.arrow_drop_down : MaterialIcons.keyboard_arrow_down } else { return (isShape) ? MaterialIcons.arrow_right : MaterialIcons.keyboard_arrow_right } } onClicked: { isExpanded = !isExpanded } enabled: true ToolTip.text: isExpanded ? "Collapse" : "Expand" ToolTip.visible: hovered ToolTip.delay: 800 } } // Shape color Loader { active: isShape sourceComponent: ToolButton { enabled: isAttributeEnabled contentItem: Rectangle { anchors.centerIn: parent color: isAttribute ? model.userColor : model.properties.color || "black" width: materialMetrics.height height: materialMetrics.height } onClicked: shapeColorDialog.item.open() ToolTip.text: "Shape Color" ToolTip.visible: hovered ToolTip.delay: 800 } } // Shape ColorDialog Loader { id: shapeColorDialog active: isShape && isAttributeEnabled sourceComponent: ColorDialog { title: "Edit " + model.label + " color" selectedColor: model.userColor onAccepted: { _currentScene.setAttribute(model.childAttribute("userColor"), selectedColor.toString()) close() } onRejected: close() } } // Shape type and shape name RowLayout { spacing: 2 opacity: (isAttributeEnabled && isAttributeInitialized) ? 1.0 : 0.7 // Shape type MaterialLabel { font.pointSize: 11 padding: 2 text: { switch(model.type) { case "ShapeFile": return MaterialIcons.insert_drive_file; case "ShapeList": return MaterialIcons.layers; case "Point2d": return MaterialIcons.control_camera; case "Line2d": return MaterialIcons.linear_scale; case "Circle": return MaterialIcons.radio_button_unchecked; case "Rectangle": return MaterialIcons.crop_landscape; case "Text": return MaterialIcons.title; default: return MaterialIcons.question_mark; } } } // Shape name TextField { font.pointSize: 8 background: Rectangle { color: "transparent" } palette.text: parent.palette.text maximumLength: 40 selectByMouse: true persistentSelection: false text: { if(isAttribute && isShape && model.userName) return model.userName if(isAttribute && model.root && (model.root.type === "ShapeList")) return model.rootName return model.label } enabled: isAttributeEnabled && model.root && (model.root.type === "ShapeList") onEditingFinished: { _currentScene.setAttribute(model.childAttribute("userName"), text) focus = false } } // Shape file basename Loader { active: !isShape && !isAttribute && model.basename !== "" sourceComponent: Label { font.pointSize: 8 text: "(" + model.basename + ")" } } // Shape number of observations Loader { active: isShape && (isAttribute ? model.geometry.observationKeyable : model.observationKeyable) sourceComponent: Label { text: "(" + (isAttribute ? model.geometry.nbObservations : model.nbObservations) + ")" font.pointSize: 8 } } } // Spacer Item { Layout.fillWidth: true } // Right toolbar RowLayout { spacing: 0 // Static shape, set/remove observation Loader { active: isShape && isAttribute && !model.geometry.observationKeyable sourceComponent: MaterialToolButton { font.pointSize: 11 padding: 2 text: isAttributeInitialized ? MaterialIcons.clear : MaterialIcons.edit checkable: false enabled: isAttributeEnabled onClicked: { if(isAttributeInitialized) { // remove key _currentScene.removeObservation(model, _currentScene.selectedViewId) ShapeViewerHelper.selectedShapeName = "" } else { // add key _currentScene.setObservation(model, _currentScene.selectedViewId, ShapeViewerHelper.getDefaultObservation(model.type)) ShapeViewerHelper.selectedShapeName = model.fullName } } ToolTip.text: isAttributeInitialized ? "Reset Shape" : "Set Shape" ToolTip.visible: hovered ToolTip.delay: 800 } } // Shape keyable, set/remove observation Loader { active: isShape && (isAttribute ? model.geometry.observationKeyable : model.observationKeyable) sourceComponent: RowLayout { spacing: 0 property var keys: isAttribute ? model.geometry.observationKeys : model.observationKeys property bool hasCurrentKey: { if(isAttribute) return model.geometry.hasObservation(_currentScene.selectedViewId) return model.hasObservation(_currentScene.selectedViewId) } function getViewPath(viewId) { for (var i = 0; i < _currentScene.viewpoints.count; i++) { var vp = _currentScene.viewpoints.at(i) if (vp.childAttribute("viewId").value == viewId) return vp.childAttribute("path").value } return undefined } function getPrevViewId(viewIds, currentViewId) { const currentViewPath = getViewPath(currentViewId) const prevIds = viewIds.filter(viewId => getViewPath(viewId) < currentViewPath) if (prevIds.length === 0) return "-1"; prevIds.sort((a, b) => getViewPath(b).localeCompare(getViewPath(a))) return prevIds[0] } function getNextViewId(viewIds, currentViewId) { const currentViewPath = getViewPath(currentViewId) const nextIds = viewIds.filter(viewId => getViewPath(viewId) > currentViewPath) if (nextIds.length === 0) return "-1"; nextIds.sort((a, b) => getViewPath(a).localeCompare(getViewPath(b))) return nextIds[0] } // Previous key MaterialToolButton { property string prevViewId: getPrevViewId(keys, _currentScene.selectedViewId) font.pointSize: 11 padding: 2 text: MaterialIcons.keyboard_arrow_left checkable: false enabled: prevViewId !== "-1" onClicked: { _currentScene.selectedViewId = prevViewId } ToolTip.text: enabled ? "Previous Key" : "No Previous Key" ToolTip.visible: hovered ToolTip.delay: 800 } // Current key MaterialToolButton { font.pointSize: 11 padding: 2 text: MaterialIcons.noise_control_off checkable: true checked: hasCurrentKey enabled: isAttributeEnabled onClicked: { if(hasCurrentKey) { // remove key _currentScene.removeObservation(model, _currentScene.selectedViewId) ShapeViewerHelper.selectedShapeName = "" } else { // add key _currentScene.setObservation(model, _currentScene.selectedViewId, ShapeViewerHelper.getDefaultObservation(model.type)) ShapeViewerHelper.selectedShapeName = model.fullName } } ToolTip.text: checked ? "Remove current key" : "Set current key" ToolTip.visible: hovered ToolTip.delay: 800 } // Next key MaterialToolButton { property string nextViewId: getNextViewId(keys, _currentScene.selectedViewId) font.pointSize: 11 padding: 2 text: MaterialIcons.keyboard_arrow_right checkable: false enabled: nextViewId !== "-1" onClicked: { _currentScene.selectedViewId = nextViewId } ToolTip.text: enabled ? "Next Key" : "No Next Key" ToolTip.visible: hovered ToolTip.delay: 800 } } } // Shape list add element Loader { active: !isShape && isAttributeEnabled sourceComponent: MaterialToolButton { font.pointSize: 11 padding: 2 text: MaterialIcons.control_point onClicked: _currentScene.appendAttribute(model, undefined) ToolTip.text: "Add Element" ToolTip.visible: hovered ToolTip.delay: 800 } } // Shape list delete element Loader { active: isAttributeEnabled && model.root && (model.root.type === "ShapeList") sourceComponent: MaterialToolButton { font.pointSize: 11 padding: 2 text: MaterialIcons.remove_circle_outline onClicked: { _currentScene.removeAttribute(model) } ToolTip.text: "Remove Element" ToolTip.visible: hovered ToolTip.delay: 800 } } // Shape is a link or locked Loader { active: !isAttributeEnabled sourceComponent: MaterialLabel { font.pointSize: 11 padding: 2 opacity: 0.4 text: isAttribute && (model.isLink || isLinkChild) ? MaterialIcons.link : MaterialIcons.lock } } } } } ================================================ FILE: meshroom/ui/qml/Shapes/Editor/ShapeEditor.qml ================================================ import QtQuick import QtQuick.Layouts import QtQuick.Controls /** * ShapeEditor * * @biref A component to display and edit the shape attributes and shape files * of the current node. * @param model - the given current node list of attributes * @param filterText - the given label filter string */ Item { id: shapeEditor // Properties property alias model: attributeslist.model property string filterText: "" Pane { anchors.fill: parent anchors.margins: 2 padding: 5 background: Rectangle { color: Qt.darker(parent.palette.window, 1.4) } ScrollView { anchors.fill: parent // Disable horizontal scrolling ScrollBar.horizontal.policy: ScrollBar.AlwaysOff // Ensure that vertical scrolling is always enabled when necessary ScrollBar.vertical.policy: ScrollBar.AlwaysOn ScrollBar.vertical.visible: contentHeight > height ColumnLayout { anchors.fill: parent spacing: 0 // Shape attributes ListView { id: attributeslist spacing: 0 interactive: false // Layout Layout.fillWidth: true Layout.preferredHeight: contentHeight delegate: ShapeEditorItem { model: object active: object.hasDisplayableShape && object.matchText(filterText) width: ListView.view.width } } // Shape files ListView { spacing: 0 interactive: false // Layout Layout.fillWidth: true Layout.preferredHeight: contentHeight model: ShapeFilesHelper.nodeShapeFiles delegate: ShapeEditorItem { model: object width: ListView.view.width } } } // Reset selection TapHandler { acceptedButtons: Qt.LeftButton gesturePolicy: TapHandler.WithinBounds onTapped: { ShapeViewerHelper.selectedShapeName = "" } } } } } ================================================ FILE: meshroom/ui/qml/Shapes/Editor/ShapeEditorItem.qml ================================================ import QtQuick import "Items" as ShapeEditorItems /** * ShapeEditorItem * * @biref ShapeEditor item loader. * Choose the correct component for each models * @param model - the given ShapeAttribute / ShapeListAttribute / ShapeFile */ Loader { id: itemLoader // Properties property var model: null // Source component sourceComponent: { switch(itemLoader.model.type) { case "ShapeFile": return shapeFileComponent case "ShapeList": return shapeListAttributeComponent default: return shapeAttributeComponent } } // ShapeFile component Component { id: shapeFileComponent ShapeEditorItems.ShapeFileItem { shapeFile: itemLoader.model } } // ShapeListAttribute component Component { id: shapeListAttributeComponent ShapeEditorItems.ShapeListAttributeItem { shapeListAttribute: itemLoader.model } } // ShapeAttribute component Component { id: shapeAttributeComponent; ShapeEditorItems.ShapeAttributeItem { shapeAttribute: itemLoader.model } } } ================================================ FILE: meshroom/ui/qml/Shapes/Viewer/Layers/BaseLayer.qml ================================================ import QtQuick /** * BaseLayer * * @biref Shape layer base component for displaying and modifying shapes. * @param name - the given shape name * @param properties - the given shape style properties * @param observation - the given shape position and dimensions for the current view * @param editable - the shape is editable * @param scaleRatio - the shape container scale ratio (scroll zoom) * @param selected - the shape is selected */ Item { id: baseLayer // Shape layer fills the parent anchors.fill: parent // Shape name property string name: "unknown" // Shape properties property var properties: ({}) // Shape observation property var observation: ({}) // Shape is editable property bool editable: false // Shape container scale ratio property real scaleRatio: 1.0 // Shape is selected property bool selected: ShapeViewerHelper.selectedShapeName === name // Shape default color readonly property color defaultColor: "#3366cc" // Request selection function selectionRequested() { ShapeViewerHelper.selectedShapeName = name } // Helper function to get scaled handle size function getScaledHandleSize() { return Math.max(0.5, 8.0 * scaleRatio) } // Helper function to get scaled stroke width function getScaledStrokeWidth() { return Math.max(0.05, (baseLayer.properties.strokeWidth || 2.0) * baseLayer.scaleRatio) } // Helper function to get scaled helper stroke width function getScaledHelperStrokeWidth() { return Math.max(0.05, baseLayer.scaleRatio) } // Helper function to get scaled font size function getScaledFontSize() { return Math.max(1.0, (baseLayer.properties.fontSize || 10.0) * baseLayer.scaleRatio) } } ================================================ FILE: meshroom/ui/qml/Shapes/Viewer/Layers/CircleLayer.qml ================================================ import QtQuick import QtQuick.Shapes import "Utils" as LayerUtils /** * CircleLayer * * @biref Allows to display and modify a circle. * @param name - the given shape name * @param properties - the given shape style properties * @param observation - the given shape position and dimensions for the current view * @param editable - the shape is editable * @param scaleRatio - the shape container scale ratio (scroll zoom) * @param selected - the shape is selected * @see BaseLayer.qml */ BaseLayer { id: circleLayer // Circle radius from handleRadius position property real circleRadius: Math.max(1.0, Math.sqrt(Math.pow(handleRadius.x - handleCenter.x, 2) + Math.pow(handleRadius.y - handleCenter.y, 2))) // Circle shape Shape { id: draggableShape // Circle path ShapePath { fillColor: circleLayer.properties.fillColor || "transparent" strokeColor: circleLayer.properties.strokeColor || circleLayer.properties.color || circleLayer.defaultColor strokeWidth: getScaledStrokeWidth() // Circle PathRectangle { x: circleLayer.observation.center.x - circleRadius y: circleLayer.observation.center.y - circleRadius width: circleRadius * 2 height: circleRadius * 2 radius: circleRadius } // Center cross PathMove { x: circleLayer.observation.center.x - 10; y: circleLayer.observation.center.y } PathLine { x: circleLayer.observation.center.x + 10; y: circleLayer.observation.center.y } PathMove { x: circleLayer.observation.center.x; y: circleLayer.observation.center.y - 10 } PathLine { x: circleLayer.observation.center.x; y: circleLayer.observation.center.y + 10 } } // Radius helper path ShapePath { fillColor: "transparent" strokeColor: circleLayer.selected ? "#bbffffff" : "transparent" strokeWidth: getScaledHelperStrokeWidth() PathMove { x: circleLayer.observation.center.x; y: circleLayer.observation.center.y } PathLine { x: handleRadius.x; y: handleRadius.y } } // Selection area MouseArea { x: handleCenter.x - circleRadius y: handleCenter.y - circleRadius width: circleRadius * 2 height: circleRadius * 2 acceptedButtons: Qt.LeftButton cursorShape: circleLayer.editable ? Qt.PointingHandCursor : Qt.ArrowCursor onClicked: selectionRequested() enabled: circleLayer.editable && !circleLayer.selected } // Handle for circle center LayerUtils.Handle { id: handleCenter x: circleLayer.observation.center.x || 0 y: circleLayer.observation.center.y || 0 size: getScaledHandleSize() target: draggableShape cursorShape: Qt.SizeAllCursor visible: circleLayer.editable && circleLayer.selected onMoved: { _currentScene.setObservationFromName(circleLayer.name, _currentScene.selectedViewId, { center: { x: handleCenter.x + draggableShape.x, y: handleCenter.y + draggableShape.y } }) } } // Handle for circle radius LayerUtils.Handle { id: handleRadius x: circleLayer.observation.center.x + circleLayer.observation.radius || 0 y: circleLayer.observation.center.y || 0 size: getScaledHandleSize() cursorShape: Qt.SizeBDiagCursor visible: circleLayer.editable && circleLayer.selected onMoved: { _currentScene.setObservationFromName(circleLayer.name, _currentScene.selectedViewId, { radius: circleRadius }) } } } } ================================================ FILE: meshroom/ui/qml/Shapes/Viewer/Layers/LineLayer.qml ================================================ import QtQuick import QtQuick.Shapes import "Utils" as LayerUtils /** * LineLayer * * @biref Allows to display and modify a line. * @param name - the given shape name * @param properties - the given shape style properties * @param observation - the given shape position and dimensions for the current view * @param editable - the shape is editable * @param scaleRatio - the shape container scale ratio (scroll zoom) * @param selected - the shape is selected * @see BaseLayer.qml */ BaseLayer { id: lineLayer // Line center from handleA and handleB position property point lineCenter: Qt.point((handleA.x + handleB.x) * 0.5, (handleA.y + handleB.y) * 0.5) // Line angle from handleA and handleB position property real lineAngle: Math.atan2(handleB.y - handleA.y, handleB.x - handleA.x) // Line distance from handleA and handleB position property real lineDistance: Math.max(1.0, Math.sqrt(Math.pow(handleA.x - handleB.x, 2) + Math.pow(handleA.y - handleB.y, 2))) // Line shape Shape { id: draggableLine // Line path ShapePath { fillColor: "transparent" strokeColor: lineLayer.properties.strokeColor || lineLayer.properties.color || lineLayer.defaultColor strokeWidth: getScaledStrokeWidth() // Line PathMove { x: handleA.x; y: handleA.y } PathLine { x: handleB.x; y: handleB.y } // Orientation center arrow PathMove { x: lineCenter.x - lineDistance * 0.1 * Math.cos(lineAngle - Math.PI * 0.25) y: lineCenter.y - lineDistance * 0.1 * Math.sin(lineAngle - Math.PI * 0.25) } PathLine { x: lineCenter.x; y: lineCenter.y } PathLine { x: lineCenter.x - lineDistance * 0.1 * Math.cos(lineAngle + Math.PI * 0.25) y: lineCenter.y - lineDistance * 0.1 * Math.sin(lineAngle + Math.PI * 0.25) } } // Selection area MouseArea { x: Math.min(handleA.x, handleB.x) y: Math.min(handleA.y, handleB.y) width: Math.abs(handleA.x - handleB.x) height: Math.abs(handleA.y - handleB.y) acceptedButtons: Qt.LeftButton cursorShape: lineLayer.editable ? Qt.PointingHandCursor : Qt.ArrowCursor onClicked: selectionRequested() enabled: lineLayer.editable && !lineLayer.selected } // Handle for point A LayerUtils.Handle { id: handleA x: lineLayer.observation.a.x || 0 y: lineLayer.observation.a.y || 0 size: getScaledHandleSize() cursorShape: Qt.SizeAllCursor visible: lineLayer.editable && lineLayer.selected onMoved: { _currentScene.setObservationFromName(lineLayer.name, _currentScene.selectedViewId, { a: { x: handleA.x + draggableLine.x, y: handleA.y + draggableLine.y } }) } } // Handle for point B LayerUtils.Handle { id: handleB x: lineLayer.observation.b.x || 0 y: lineLayer.observation.b.y || 0 size: getScaledHandleSize() cursorShape: Qt.SizeAllCursor visible: lineLayer.editable && lineLayer.selected onMoved: { _currentScene.setObservationFromName(lineLayer.name, _currentScene.selectedViewId, { b: { x: handleB.x + draggableLine.x, y: handleB.y + draggableLine.y } }) } } // Handle for line center LayerUtils.Handle { id: handleCenter x: lineCenter.x y: lineCenter.y size: getScaledHandleSize() target: draggableLine cursorShape: Qt.SizeAllCursor visible: lineLayer.editable && lineLayer.selected onMoved: { _currentScene.setObservationFromName(lineLayer.name, _currentScene.selectedViewId, { a: { x: handleA.x + draggableLine.x, y: handleA.y + draggableLine.y }, b: { x: handleB.x + draggableLine.x, y: handleB.y + draggableLine.y } }) } } } } ================================================ FILE: meshroom/ui/qml/Shapes/Viewer/Layers/PointLayer.qml ================================================ import QtQuick import QtQuick.Shapes import "Utils" as LayerUtils /** * PointLayer * * @biref Allows to display and modify a 2d point. * @param name - the given shape name * @param properties - the given shape style properties * @param observation - the given shape position and dimensions for the current view * @param editable - the shape is editable * @param scaleRatio - the shape container scale ratio (scroll zoom) * @param selected - the shape is selected * @see BaseLayer.qml */ BaseLayer { id: pointLayer // Point size and half size property real pointSize: Math.max(1.0, 12.0 * scaleRatio) property real pointHalfSize: pointSize * 0.5 // Point shape Shape { id: draggableShape // Center cross path ShapePath { fillColor: "transparent" strokeColor: selected ? "#ffffff" : pointLayer.properties.color || pointLayer.defaultColor strokeWidth: getScaledStrokeWidth() PathMove { x: pointLayer.observation.x - pointSize; y: pointLayer.observation.y } PathLine { x: pointLayer.observation.x + pointSize; y: pointLayer.observation.y } PathMove { x: pointLayer.observation.x; y: pointLayer.observation.y - pointSize } PathLine { x: pointLayer.observation.x; y: pointLayer.observation.y + pointSize } } // Selection area MouseArea { x: handleCenter.x - pointSize y: handleCenter.y - pointSize width: pointSize * 2 height: pointSize * 2 acceptedButtons: Qt.LeftButton cursorShape: pointLayer.editable ? Qt.PointingHandCursor : Qt.ArrowCursor onClicked: selectionRequested() enabled: pointLayer.editable && !pointLayer.selected } // Handle for point center LayerUtils.Handle { id: handleCenter x: pointLayer.observation.x || 0 y: pointLayer.observation.y || 0 size: getScaledHandleSize() target: draggableShape cursorShape: Qt.SizeAllCursor visible: pointLayer.editable && pointLayer.selected onMoved: { _currentScene.setObservationFromName(pointLayer.name, _currentScene.selectedViewId, { x: handleCenter.x + draggableShape.x, y: handleCenter.y + draggableShape.y }) } } // Point name Rectangle { x: (pointLayer.observation.x || 0) + pointHalfSize y: (pointLayer.observation.y || 0) + pointHalfSize width: pointName.width height: pointName.height visible: pointLayer.editable && scaleRatio > 0.2 color: selected ? palette.shadow : palette.window Text { id: pointName text: { if(pointLayer.properties.userName && pointLayer.properties.userName.length > 0) return pointLayer.properties.userName const lastDotIndex = pointLayer.name.lastIndexOf('.') if(lastDotIndex < 0) return pointLayer.name return pointLayer.name.substring(lastDotIndex + 1); } color: selected ? palette.highlightedText : palette.text padding: 0 rightPadding: Math.max(1, 2 * scaleRatio) leftPadding: rightPadding wrapMode: Text.NoWrap font.pixelSize: getScaledFontSize() } } } } ================================================ FILE: meshroom/ui/qml/Shapes/Viewer/Layers/RectangleLayer.qml ================================================ import QtQuick import QtQuick.Shapes import "Utils" as LayerUtils /** * RectangleLayer * * @biref Allows to display and modify a rectangle. * @param name - the given shape name * @param properties - the given shape style properties * @param observation - the given shape position and dimensions for the current view * @param editable - the shape is editable * @param scaleRatio - the shape container scale ratio (scroll zoom) * @param selected - the shape is selected * @see BaseLayer.qml */ BaseLayer { id: rectangleLayer // Rectangle width from handleWidth position property real rectangleWidth: Math.max(1.0, Math.abs(handleCenter.x- handleWidth.x) * 2) // Rectangle height from handleHeight position property real rectangleHeight: Math.max(1.0, Math.abs(handleCenter.y - handleHeight.y) * 2) // Rectangle shape Shape { id : draggableRectangle // Rectangle path ShapePath { fillColor: rectangleLayer.properties.fillColor || "transparent" strokeColor: rectangleLayer.properties.strokeColor || rectangleLayer.properties.color || rectangleLayer.defaultColor strokeWidth: getScaledStrokeWidth() PathRectangle { x: rectangleLayer.observation.center.x - (rectangleWidth * 0.5) y: rectangleLayer.observation.center.y - (rectangleHeight * 0.5) width: rectangleWidth height: rectangleHeight } } // Size helper path ShapePath { fillColor: "transparent" strokeColor: rectangleLayer.selected ? "#bbffffff" : "transparent" strokeWidth: getScaledHelperStrokeWidth() PathMove { x: rectangleLayer.observation.center.x; y: rectangleLayer.observation.center.y } PathLine { x: handleWidth.x; y: handleWidth.y } PathMove { x: rectangleLayer.observation.center.x; y: rectangleLayer.observation.center.y } PathLine { x: handleHeight.x; y: handleHeight.y } } // Selection area MouseArea { x: handleCenter.x - rectangleWidth * 0.5 y: handleCenter.y - rectangleHeight * 0.5 width: rectangleWidth height: rectangleHeight acceptedButtons: Qt.LeftButton cursorShape: rectangleLayer.editable ? Qt.PointingHandCursor : Qt.ArrowCursor onClicked: selectionRequested() enabled: rectangleLayer.editable && !rectangleLayer.selected } // Handle for rectangle center LayerUtils.Handle { id: handleCenter x: rectangleLayer.observation.center.x || 0 y: rectangleLayer.observation.center.y || 0 size: getScaledHandleSize() target: draggableRectangle cursorShape: Qt.SizeAllCursor visible: rectangleLayer.editable && rectangleLayer.selected onMoved: { _currentScene.setObservationFromName(rectangleLayer.name, _currentScene.selectedViewId, { center: { x: handleCenter.x + draggableRectangle.x, y: handleCenter.y + draggableRectangle.y, } }) } } // Handle for rectangle width LayerUtils.Handle { id: handleWidth x: rectangleLayer.observation.center.x + (rectangleLayer.observation.size.width * 0.5) || 0 y: handleCenter.y || 0 size: getScaledHandleSize() yAxisEnabled: false cursorShape: Qt.SizeHorCursor visible: rectangleLayer.editable && rectangleLayer.selected onMoved: { _currentScene.setObservationFromName(rectangleLayer.name, _currentScene.selectedViewId, { size: { width: rectangleWidth, height: rectangleHeight } }) } } // Handle for rectangle height LayerUtils.Handle { id: handleHeight x: rectangleLayer.observation.center.x || 0 y: rectangleLayer.observation.center.y - (rectangleLayer.observation.size.height * 0.5) || 0 size: getScaledHandleSize() xAxisEnabled: false cursorShape: Qt.SizeVerCursor visible: rectangleLayer.editable && rectangleLayer.selected onMoved: { _currentScene.setObservationFromName(rectangleLayer.name, _currentScene.selectedViewId, { size: { width: rectangleWidth, height: rectangleHeight } }) } } } } ================================================ FILE: meshroom/ui/qml/Shapes/Viewer/Layers/TextLayer.qml ================================================ import QtQuick /** * TextLayer * * @biref Allows to display a text. * @param name - the given shape name * @param properties - the given shape style properties * @param observation - the given shape position and dimensions for the current view * @param editable - the shape is editable * @param scaleRatio - the shape container scale ratio (scroll zoom) * @param selected - the shape is selected * @see BaseLayer.qml */ BaseLayer { id: textLayer Text { x: textLayer.observation.center.x - implicitWidth * 0.5 // Center text horizontally y: textLayer.observation.center.y - implicitHeight * 0.5 // Center text vertically text: textLayer.observation.content || "Undefined" color: textLayer.properties.color || textLayer.defaultColor wrapMode: Text.NoWrap font.family: textLayer.properties.fontFamily || "Arial" font.pixelSize: getScaledFontSize() } } ================================================ FILE: meshroom/ui/qml/Shapes/Viewer/Layers/Utils/Handle.qml ================================================ import QtQuick /** * Handle * * @biref Handle component to centralize handle behavior and avoid code duplication. * @param size - the handle display size * @param target - the handle drag target * @param xAxisEnabled - the handle x-axis is draggable * @param yAxisEnabled - the handle y-axis is draggable * @param cursorShape - the handle cursor shape */ Rectangle { id: root // Handle moved signal signal moved() // Handle display size property real size : 10.0 // Handle drag target property alias target: dragHandler.target // Handle drag x-axis enabled property bool xAxisEnabled : true // Handle drag y-axis enabled property bool yAxisEnabled : true // Handle cursor shape property alias cursorShape : dragHandler.cursorShape // Handle does not have a true size // Width and height should always be 0 width: 0 height: 0 // Handle hover handler HoverHandler { cursorShape: dragHandler.cursorShape grabPermissions: PointerHandler.CanTakeOverFromAnything margin: root.size * 2 // Handle interaction area enabled: root.visible } // Handle drag handler DragHandler { id: dragHandler cursorShape: Qt.SizeBDiagCursor grabPermissions: PointerHandler.CanTakeOverFromAnything xAxis.enabled: root.xAxisEnabled yAxis.enabled: root.yAxisEnabled margin: root.size * 2 // Handle interaction area onActiveChanged: { if (!active) { root.moved() } } enabled: root.visible } // Handle shape Rectangle { x: root.size * -0.5 y: root.size * -0.5 width: root.size height: root.size color: "#ffffff" } // Handle outline Rectangle { x: width * -0.5 y: height * -0.5 width: 1.5 * root.size height: 1.5 * root.size color: "#66666666" z: -1 } } ================================================ FILE: meshroom/ui/qml/Shapes/Viewer/ShapeViewer.qml ================================================ import QtQuick /** * ShapeViewer * * @biref A canvas to display current node shape attributes and shape files. * @param containerWidth - the parent image container width * @param containerHeight - the parent image container height * @param containerScale - the parent image container scale */ Item { id: shapeViewer // Current node property var node: _currentScene ? _currentScene.selectedNode : null // Container dimensions and scale property real containerWidth: 0.0 property real containerHeight: 0.0 property real containerScale: 1.0 // Container scale ratio property real scaleRatio: (1 / containerScale) // Update ShapeViewerHelper // This is usefull for new observation initialization onContainerWidthChanged: { ShapeViewerHelper.containerWidth = shapeViewer.containerWidth } onContainerHeightChanged: { ShapeViewerHelper.containerHeight = shapeViewer.containerHeight } onContainerScaleChanged: { ShapeViewerHelper.containerScale = shapeViewer.containerScale } // Current node shape files // ShapeFilesHelper provide the model Repeater { model: ShapeFilesHelper.nodeShapeFiles delegate: Repeater { model: object.shapes delegate: ShapeViewerLayer { active: object.isVisible scaleRatio: shapeViewer.scaleRatio name: object.name type: object.type properties: object.properties observation: object.observation editable: false } } } // Current node shape attributes // Node attributes as the model Repeater { model: node.attributes delegate: ShapeViewerAttributeLoader { attribute: object scaleRatio: shapeViewer.scaleRatio } } // Reset selection TapHandler { acceptedButtons: Qt.LeftButton gesturePolicy: TapHandler.WithinBounds onTapped: { ShapeViewerHelper.selectedShapeName = "" } } } ================================================ FILE: meshroom/ui/qml/Shapes/Viewer/ShapeViewerAttributeLayer.qml ================================================ import QtQuick /** * ShapeViewerAttributeLayer * * @biref Shape attribute layer loader. * @param shapeAttribute - the given shape attribute * @param isLinkChild - Whether the given attribute is a child of a linked attribute * @param scaleRatio - the container scale ratio (scroll zoom) */ Loader { // Properties property var shapeAttribute property bool isLinkChild: false property real scaleRatio: 1.0 // Source component sourceComponent: shapeAttributeLayerComponent // Reload source component // When attribute observations changed (signal) // For now, ShapeLayer should be re-build when observation changed Connections { target: shapeAttribute.geometry function onObservationsChanged() { sourceComponent = null sourceComponent = shapeAttributeLayerComponent } } // Shape attribute layer component Component { id: shapeAttributeLayerComponent Loader { sourceComponent: ShapeViewerLayer { scaleRatio: shapeViewer.scaleRatio name: shapeAttribute.fullName type: shapeAttribute.type properties: ({"color" : shapeAttribute.userColor, "userName" : shapeAttribute.userName}) observation: shapeAttribute.geometry.getObservation(_currentScene ? _currentScene.selectedViewId : "-1") editable: shapeAttribute.enabled && !shapeAttribute.isLink && !isLinkChild } } } } ================================================ FILE: meshroom/ui/qml/Shapes/Viewer/ShapeViewerAttributeLoader.qml ================================================ import QtQuick /** * ShapeViewerAttributeLoader * * @biref ShapeViewer attribute loader. * @param attribute - the given attribute (ShapeAttribute or ShapeListAttribute) * @param scaleRatio - the container scale ratio (scroll zoom) */ Loader { id: attributeLoader // Properties property var attribute property real scaleRatio: 1.0 // Attribute should be shape or shape list // Attribute should be visible active: attribute.hasDisplayableShape && attribute.isVisible // Source component sourceComponent: { if(attribute.type === "ShapeList") return shapeListAttributeComponent return shapeAttributeComponent } // Shape attribute component Component { id: shapeAttributeComponent ShapeViewerAttributeLayer { active: !attribute.geometry.isDefault shapeAttribute: attribute scaleRatio: attributeLoader.scaleRatio } } // Shape list attribute component Component { id: shapeListAttributeComponent Repeater { model: attribute.value delegate: ShapeViewerAttributeLayer { active: object.isVisible && !object.geometry.isDefault shapeAttribute: object isLinkChild: attribute.isLink scaleRatio: attributeLoader.scaleRatio } } } } ================================================ FILE: meshroom/ui/qml/Shapes/Viewer/ShapeViewerLayer.qml ================================================ import QtQuick import "Layers" as ShapeViewerLayers /** * ShapeViewerLayer * * @biref Load the corresponding shape layer. * @param type - the given shape type * @param name - the given shape name * @param properties - the given shape style properties * @param observation - the given shape position and dimensions for the current view * @param editable - the shape is editable * @param scaleRatio - the container scale ratio (scroll zoom) */ Loader { id: layerLoader // Properties property string type property string name property var properties property var observation property bool editable: false property real scaleRatio: 1.0 // Source component sourceComponent: { if (!properties || !observation) return; switch (type) { case "Point2d": return pointLayerComponent case "Line2d": return lineLayerComponent case "Circle": return circleLayerComponent case "Rectangle": return rectangleLayerComponent case "Text": return textLayerComponent } } // PointLayer component Component { id: pointLayerComponent ShapeViewerLayers.PointLayer { name: layerLoader.name properties: layerLoader.properties observation: layerLoader.observation editable: layerLoader.editable scaleRatio: layerLoader.scaleRatio } } // LineLayer component Component { id: lineLayerComponent ShapeViewerLayers.LineLayer { name: layerLoader.name properties: layerLoader.properties observation: layerLoader.observation editable: layerLoader.editable scaleRatio: layerLoader.scaleRatio } } // CircleLayer component Component { id: circleLayerComponent ShapeViewerLayers.CircleLayer { name: layerLoader.name properties: layerLoader.properties observation: layerLoader.observation editable: layerLoader.editable scaleRatio: layerLoader.scaleRatio } } // RectangleLayer component Component { id: rectangleLayerComponent ShapeViewerLayers.RectangleLayer { name: layerLoader.name properties: layerLoader.properties observation: layerLoader.observation editable: layerLoader.editable scaleRatio: layerLoader.scaleRatio } } // TextLayer component Component { id: textLayerComponent ShapeViewerLayers.TextLayer { name: layerLoader.name properties: layerLoader.properties observation: layerLoader.observation editable: layerLoader.editable scaleRatio: layerLoader.scaleRatio } } } ================================================ FILE: meshroom/ui/qml/Shapes/qmldir ================================================ module Shapes ShapeEditor 1.0 Editor/ShapeEditor.qml ShapeViewer 1.0 Viewer/ShapeViewer.qml ================================================ FILE: meshroom/ui/qml/Utils/Clipboard.qml ================================================ pragma Singleton import Meshroom.Helpers 1.0 /** * Clipboard singleton object to copy values to paste buffer. */ ClipboardHelper { } ================================================ FILE: meshroom/ui/qml/Utils/Colors.qml ================================================ pragma Singleton import QtQuick import QtQuick.Controls /** * Singleton that gathers useful colors, shades and system palettes. */ QtObject { property SystemPalette sysPalette: SystemPalette {} property SystemPalette disabledSysPalette: SystemPalette { colorGroup: SystemPalette.Disabled } readonly property color green: "#4CAF50" readonly property color orange: "#FF9800" readonly property color yellow: "#FFEB3B" readonly property color red: "#F44336" readonly property color crimson: "#DC143C" readonly property color firebrick: "#B22222" readonly property color blue: "#03A9F4" readonly property color cyan: "#00BCD4" readonly property color pink: "#E91E63" readonly property color lime: "#CDDC39" readonly property color grey: "#555555" readonly property color lightgrey: "#999999" readonly property color darkpurple: "#5c4885" readonly property var statusColors: { "NONE": "transparent", "SUBMITTED": cyan, "RUNNING": orange, "ERROR": red, "SUCCESS": green, "STOPPED": pink, "INPUT": "transparent" } readonly property var ghostColors: { "SUBMITTED": Qt.darker(cyan, 1.5), "RUNNING": Qt.darker(orange, 1.5), "STOPPED": Qt.darker(pink, 1.5) } readonly property var statusColorsExternOverrides: { "SUBMITTED": "#2196F3" } readonly property var durationColorScale: [ {"time": 0, "color": grey}, {"time": 5, "color": green}, {"time": 20, "color": yellow}, {"time": 90, "color": red} ] function getChunkColor(chunk, overrides) { if (chunk === undefined) return "transparent" if (overrides && chunk.statusName in overrides) { return overrides[chunk.statusName] } else if (chunk.execModeName === "EXTERN" && chunk.statusName in statusColorsExternOverrides) { return statusColorsExternOverrides[chunk.statusName] } else if (chunk.nodeName !== chunk.statusNodeName && chunk.statusName in ghostColors) { return ghostColors[chunk.statusName] } else if (chunk.statusName in statusColors) { return statusColors[chunk.statusName] } console.warn("Unknown status : " + chunk.status) return "magenta" } function getNodeColor(node, overrides) { if (node === undefined) return "transparent" if (overrides && node.globalStatus in overrides) { return overrides[node.globalStatus] } else if (node.globalExecMode === "EXTERN" && node.globalStatus in statusColorsExternOverrides) { return statusColorsExternOverrides[node.globalStatus] } else if (node.name !== node.nodeStatusNodeName && node.globalStatus in ghostColors) { return ghostColors[node.globalStatus] } else if (node.globalStatus in statusColors) { return statusColors[node.globalStatus] } console.warn("Unknown status : " + node.globalStatus) return "magenta" } function toRgb(color) { return [ parseInt(color.toString().substr(1, 2), 16) / 255, parseInt(color.toString().substr(3, 2), 16) / 255, parseInt(color.toString().substr(5, 2), 16) / 255 ] } function interpolate(c1, c2, u) { let rgb1 = toRgb(c1) let rgb2 = toRgb(c2) return Qt.rgba( rgb1[0] * (1 - u) + rgb2[0] * u, rgb1[1] * (1 - u) + rgb2[1] * u, rgb1[2] * (1 - u) + rgb2[2] * u ) } function durationColor(t) { if (t < durationColorScale[0].time) { return durationColorScale[0].color } if (t > durationColorScale[durationColorScale.length-1].time) { return durationColorScale[durationColorScale.length-1].color } for (let idx = 1; idx < durationColorScale.length; idx++) { if (t < durationColorScale[idx].time) { let u = (t - durationColorScale[idx - 1].time) / (durationColorScale[idx].time - durationColorScale[idx - 1].time) return interpolate(durationColorScale[idx - 1].color, durationColorScale[idx].color, u) } } } } ================================================ FILE: meshroom/ui/qml/Utils/ExifOrientation.qml ================================================ pragma Singleton import QtQuick /** * Singleton that defines utility functions for supporting exif orientation tags. * * If you are looking for a way to create a Loader that supports exif orientation tags, * you can directly use ExifOrientedViewer instead. * * However if you want to apply an exif orientation tag to another type of QML component, * you will need to redefine its transform property using the utility methods given below. */ QtObject { function rotation(orientationTag) { switch(orientationTag) { case "3": return 180; case "4": return 180; case "5": return 90; case "6": return 90; case "7": return -90; case "8": return -90; default: return 0; } } function xscale(orientationTag) { switch(orientationTag) { case "2": return -1; case "4": return -1; case "5": return -1; case "7": return -1; default: return 1; } } } ================================================ FILE: meshroom/ui/qml/Utils/ExpressionTextField.qml ================================================ import QtQuick import QtQuick.Controls TextField { id: root // evaluated numeric value (NaN if invalid) // It helps keeping the connection that text has so that we do not lose ability to undo/reset property bool exprTextChanged: false property real evaluatedValue: 0 property bool hasExprError: false property bool isInt: false // Overlay for error state (red border on top of default background) Rectangle { anchors.fill: parent radius: 4 border.color: "red" color: "transparent" visible: root.hasExprError z: 1 } function raiseError() { hasExprError = true } function clearError() { hasExprError = false } function getEvalExpression(_text) { var [_res, _err] = _currentScene.evaluateMathExpression(_text) if (_err == false) { if (isInt) _res = Math.round(_res) return _res } else { console.error("Error: Expression", _text, "is invalid") return NaN } } function refreshStatus() { if (isNaN(getEvalExpression(root.text))) { raiseError() } else { clearError() } } function updateExpression() { var previousEvaluatedValue = evaluatedValue var result = getEvalExpression(root.text) if (!isNaN(result)) { evaluatedValue = result clearError() } else { evaluatedValue = previousEvaluatedValue raiseError() } exprTextChanged = false } // onAccepted and onEditingFinished will break the bindings to text // so if used on fields that needs to be driven by sliders or other qml element, // the binding needs to be restored // No need to restore the binding if the expression has an error because we do not break it onAccepted: { if (exprTextChanged) { updateExpression() if (!hasExprError && !isNaN(evaluatedValue)) { // Commit the result value to the text field if (isInt) root.text = Number(evaluatedValue).toFixed(0) else root.text = Number(evaluatedValue) } } } onEditingFinished: { if (exprTextChanged) { updateExpression() if (!hasExprError && !isNaN(evaluatedValue)) { if (isInt) root.text = Number(evaluatedValue).toFixed(0) else root.text = Number(evaluatedValue) } } } onTextChanged: { if (!activeFocus && exprTextChanged) { refreshStatus() } else { exprTextChanged = true } } Component.onDestruction: { if (exprTextChanged) { root.accepted() } } } ================================================ FILE: meshroom/ui/qml/Utils/Filepath.qml ================================================ pragma Singleton import Meshroom.Helpers 1.0 FilepathHelper { } ================================================ FILE: meshroom/ui/qml/Utils/Scene3DHelper.qml ================================================ pragma Singleton import Meshroom.Helpers 1.0 Scene3DHelper { } ================================================ FILE: meshroom/ui/qml/Utils/SortFilterDelegateModel.qml ================================================ import QtQuick import QtQml.Models import QtQuick.Controls /** * SortFilderDelegateModel adds sorting and filtering capabilities on a source model. * * The way model data is accessed can be overridden by redefining the modelData function. * This is useful if the value is not directly accessible from the model and needs * some extra logic. * * Regarding filtering, each filter is defined with a role to filter on and a value to match. * Filters can be accumulated in a 2D-array, which is evaluated with the following rules: * - on the 2nd dimension we map respectFilter and reduce with logical OR * - on the 1st dimension we map respectFilter and reduce with logical AND. * Filtering behavior can also be overridden by redefining the respectFilter function. * * Based on http://doc.qt.io/qt-5/qtquick-tutorials-dynamicview-dynamicview4-example.html */ DelegateModel { id: sortFilterModel property string sortRole: "" /// The role to use for sorting property int sortOrder: Qt.AscendingOrder /// The sorting order property var filters: [] /// Filter format: {role: "roleName", value: "filteringValue"} onSortRoleChanged: invalidateSort() onSortOrderChanged: invalidateSort() onFiltersChanged: invalidateFilters() // Display "filtered" group filterOnGroup: "filtered" // Don't include elements in "items" group by default as they must fall in the "unsorted" group items.includeByDefault: false groups: [ // Group for temporarily storing items before sorting DelegateModelGroup { id: unsortedItems name: "unsorted" includeByDefault: true // If the source model changes, perform sorting and filtering onChanged: { // No sorting: move everything from unsorted to sorted group if(sortRole == "") { unsortedItems.setGroups(0, unsortedItems.count, ["items"]) } else { sort() } // Perform filter invalidation in both cases invalidateFilters() } }, // Group for storing filtered items DelegateModelGroup { id: filteredItems name: "filtered" } ] /// Get data from model for 'roleName' function modelData(item, roleName) { return item.model[roleName] } /// Get the index of the first element which matches 'value' for the given 'roleName' function find(value, roleName) { for (var i = 0; i < filteredItems.count; ++i) { if (modelData(filteredItems.get(i), roleName) == value) return i } return -1 } /** * Return whether 'value' respects 'filter' condition * * The test is based on the value's type: * - String: check if 'value' contains 'filter' (case insensitive) * - any other type: test for equality (===) * * TODO: add case sensitivity / whole word options for Strings */ function respectFilter(value, filter) { if (filter === undefined) { return true; } switch (value.constructor.name) { case "String": return value.toLowerCase().indexOf(filter.toLowerCase()) > -1 default: return value === filter } } /// Apply respectFilter mapping and logical AND/OR reduction on filters function respectFilters(item) { let cond = (filter => respectFilter(modelData(item, filter.role), filter.value)) return filters.every(x => Array.isArray(x) ? x.some(cond) : cond(x)) } /// Reverse sort order (toggle between Qt.AscendingOrder / Qt.DescendingOrder) function reverseSortOrder() { sortOrder = sortOrder == Qt.AscendingOrder ? Qt.DescendingOrder : Qt.AscendingOrder } property var lessThan: [ function(left, right) { return modelData(left, sortRole) < modelData(right, sortRole) } ] function invalidateSort() { if (!sortFilterModel.model || !sortFilterModel.model.count) return; // Move everything from "items" to "unsorted", will trigger "unsorted" DelegateModelGroup 'changed' signal items.setGroups(0, items.count, ["unsorted"]) } /// Invalidate filtering function invalidateFilters() { for (var i = 0; i < items.count; ++i) { // If the property value contains filterText, add it to the filtered group if (respectFilters(items.get(i))) { items.addGroups(items.get(i), 1, "filtered") } else { // Otherwise, remove it from the filtered group items.removeGroups(items.get(i), 1, "filtered") } } } /// Compute insert position of 'item' based on the value of its sortProperty function insertPosition(lessThan, item) { var lower = 0 var upper = items.count while (lower < upper) { var middle = Math.floor(lower + (upper - lower) / 2) var result = lessThan(item, items.get(middle)) if (sortOrder == Qt.DescendingOrder) { result = !result } if (result) { upper = middle } else { lower = middle + 1 } } return lower } /// Perform model sorting function sort() { while (unsortedItems.count > 0) { var item = unsortedItems.get(0) var index = insertPosition(lessThan[0], item) item.groups = ["items"] items.move(item.itemsIndex, index) } // If some items were actually sorted, filter will be correctly invalidated // as unsortedGroup 'changed' signal will be triggered } } ================================================ FILE: meshroom/ui/qml/Utils/Transformations3DHelper.qml ================================================ pragma Singleton import Meshroom.Helpers 1.0 Transformations3DHelper { } ================================================ FILE: meshroom/ui/qml/Utils/errorHandler.js ================================================ .pragma library /** * Analyse raised errors. * Works only if errors are written with this specific syntax: * [Context] ErrorType: ErrorMessage * * Maybe it would be better to handle errors on Python side but it should be harder to handle Dialog customization */ function analyseError(error) { const msg = error.toString() // The use of [^] is like . but it takes in count every character including \n (works as a double negation) // Group 1: Context // Group 2: ErrorType // Group 3: ErrorMessage const regex = /\[(.*)\]\s(.*):([^]*)/ if (!regex.test(msg)) return { context: "", type: "", msg: "" } const data = regex.exec(msg) return { context: data[1], type: data[2], msg: data[3].startsWith("\n") ? data[3].slice(1) : data[3] } } ================================================ FILE: meshroom/ui/qml/Utils/format.js ================================================ .pragma library function intToString(v) { // Use EN locale to get comma separated thousands // + remove automatically added trailing decimals // (this 'toLocaleString' does not take any option) return v.toLocaleString(Qt.locale('en-US')).split('.')[0] } // Convert a plain text to an html escaped string. function plainToHtml(t) { var escaped = t.replace(/&/g, '&').replace(//g, '>') // Escape text return escaped.replace(/\n/g, '
') // Replace line breaks } function divmod(x, y) { // Perform the division and get the quotient const quotient = Math.floor(x / y) // Compute the remainder const remainder = x % y return [quotient, remainder] } function sec2timeHMS(totalSeconds) { const [totalMinutes, seconds] = divmod(totalSeconds, 60.0) const [hours, minutes] = divmod(totalMinutes, 60.0) return { hours: hours, minutes: minutes, seconds: seconds } } function sec2timecode(timeSeconds) { var pad = function(num, size) { return ('000' + num).slice(size * -1) } var timeObj = sec2timeHMS(Math.round(timeSeconds)) var timeStr = pad(timeObj.hours, 2) + ':' + pad(timeObj.minutes, 2) + ':' + pad(timeObj.seconds, 2) return timeStr } function sec2timeStr(timeSeconds) { // Need to decide the rounding precision first to propagate the right values if (timeSeconds >= 60.0) { timeSeconds = Math.round(timeSeconds) } else { timeSeconds = parseFloat(timeSeconds.toFixed(2)) } var timeObj = sec2timeHMS(timeSeconds) var timeStr = "" if (timeObj.hours > 0) { timeStr += timeObj.hours + "h" } if (timeObj.hours > 0 || timeObj.minutes > 0) { timeStr += timeObj.minutes + "m" } if (timeObj.hours === 0) { // Seconds only matter if the elapsed time is less than 1 hour if (timeObj.minutes === 0) { // If less than a minute, keep millisecond precision timeStr += timeObj.seconds.toFixed(2) + "s" } else { // If more than a minute, do not need more precision than seconds timeStr += Math.round(timeObj.seconds) + "s" } } return timeStr } function GB2GBMBKB(GB) { // Convert GB to GB, MB, KB var GBInt = Math.floor(GB) var MB = Math.floor((GB - GBInt) * 1024) var KB = Math.floor(((GB - GBInt) * 1024 - MB) * 1024) return { GB: GBInt, MB: MB, KB: KB } } function GB2SizeStr(GB) { // Convert GB to a human readable size string // e.g. 1.23GB, 456MB, 789KB // We only use one unit at a time var sizeObj = GB2GBMBKB(GB) var sizeStr = "" if (sizeObj.GB > 0) { sizeStr += sizeObj.GB if (sizeObj.MB > 0 && sizeObj.GB < 10) { sizeStr += "." + Math.floor(sizeObj.MB / 1024 * 1000) } sizeStr += "GB" } else if (sizeObj.MB > 0) { sizeStr = sizeObj.MB if (sizeObj.KB > 0 && sizeObj.MB < 10) { sizeStr += "." + Math.floor(sizeObj.KB / 1024 * 1000) } sizeStr += "MB" } else if (sizeObj.GB === 0 && sizeObj.MB === 0) { sizeStr += sizeObj.KB + "KB" } return sizeStr } ================================================ FILE: meshroom/ui/qml/Utils/qmldir ================================================ module Utils singleton Colors 1.0 Colors.qml SortFilterDelegateModel 1.0 SortFilterDelegateModel.qml Request 1.0 request.js Format 1.0 format.js ErrorHandler 1.0 errorHandler.js singleton ExifOrientation 1.0 ExifOrientation.qml # using singleton here causes random crash at application exit # singleton Clipboard 1.0 Clipboard.qml # singleton Filepath 1.0 Filepath.qml # singleton Scene3DHelper 1.0 Scene3DHelper.qml # singleton Transformations3DHelper 1.0 Transformations3DHelper.qml ExpressionTextField 1.0 ExpressionTextField.qml ================================================ FILE: meshroom/ui/qml/Utils/request.js ================================================ .pragma library /** * Perform 'GET' request on url, and bind 'callback' to onreadystatechange (with XHR object as parameter). */ function get(url, callback) { var xhr = new XMLHttpRequest(); xhr.onreadystatechange = function() { callback(xhr) } xhr.open("GET", url) xhr.send() } ================================================ FILE: meshroom/ui/qml/Viewer/CameraResponseGraph.qml ================================================ import QtQuick import QtQuick.Controls import QtQuick.Layouts import QtCharts import Charts 1.0 import Controls 1.0 import DataObjects 1.0 FloatingPane { id: root property var responsePath: null property color textColor: Colors.sysPalette.text clip: true padding: 4 CsvData { id: csvData filepath: responsePath } // To avoid interaction with components in background MouseArea { anchors.fill: parent acceptedButtons: Qt.LeftButton | Qt.RightButton | Qt.MiddleButton onPressed: {} onReleased: {} onWheel: {} } // Note: We need to use csvData.getNbColumns() slot instead of the csvData.nbColumns property to avoid a crash on linux. property bool crfReady: csvData && csvData.ready && (csvData.getNbColumns() >= 4) onCrfReadyChanged: { if (crfReady) { redCurve.clear() greenCurve.clear() blueCurve.clear() csvData.getColumn(1).fillChartSerie(redCurve) csvData.getColumn(2).fillChartSerie(greenCurve) csvData.getColumn(3).fillChartSerie(blueCurve) } else { redCurve.clear() greenCurve.clear() blueCurve.clear() } } Item { anchors.horizontalCenter: parent.horizontalCenter anchors.verticalCenter: parent.verticalCenter anchors.horizontalCenterOffset: -responseChart.width/2 anchors.verticalCenterOffset: -responseChart.height/2 InteractiveChartView { id: responseChart width: root.width > 400 ? 400 : (root.width < 350 ? 350 : root.width) height: width * 0.75 title: "Camera Response Function (CRF)" legend.visible: false antialiasing: true ValueAxis { id: valueAxisX labelFormat: "%i" titleText: "Camera Brightness" min: crfReady ? csvData.getColumn(0).getFirst() : 0 max: crfReady ? csvData.getColumn(0).getLast() : 1 } ValueAxis { id: valueAxisY titleText: "Normalized Radiance" min: 0.0 max: 1.0 } // We cannot use a Repeater with these Components so we need to instantiate them one by one LineSeries { // Red curve id: redCurve axisX: valueAxisX axisY: valueAxisY name: crfReady ? csvData.getColumn(1).title : "" color: name.toLowerCase() } LineSeries { // Green curve id: greenCurve axisX: valueAxisX axisY: valueAxisY name: crfReady ? csvData.getColumn(2).title : "" color: name.toLowerCase() } LineSeries { // Blue curve id: blueCurve axisX: valueAxisX axisY: valueAxisY name: crfReady ? csvData.getColumn(3).title : "" color: name.toLowerCase() } } Item { id: btnContainer anchors.bottom: responseChart.bottom anchors.bottomMargin: 35 anchors.left: responseChart.left anchors.leftMargin: responseChart.width * 0.15 RowLayout { ChartViewCheckBox { text: "ALL" color: textColor checkState: legend.buttonGroup.checkState onClicked: { const _checked = checked for (let i = 0; i < responseChart.count; ++i) { responseChart.series(i).visible = _checked } } } ChartViewLegend { id: legend chartView: responseChart } } } } } ================================================ FILE: meshroom/ui/qml/Viewer/CircleGizmo.qml ================================================ import QtQuick Item { id: root property bool readOnly: false signal moved(real xoffset, real yoffset) signal incrementRadius(real radiusOffset) // Circle property real circleX: 0. property real circleY: 0. Rectangle { id: circle width: radius * 2 height: width x: circleX + (root.width - width) / 2 y: circleY + (root.height - height) / 2 color: "transparent" border.width: 5 border.color: readOnly ? "green" : "yellow" // Cross to visualize the circle center Rectangle { color: parent.border.color anchors.centerIn: parent width: parent.width * 0.2 height: parent.border.width * 0.5 } Rectangle { color: parent.border.color anchors.centerIn: parent width: parent.border.width * 0.5 height: parent.height * 0.2 } Loader { anchors.fill: parent active: !root.readOnly sourceComponent: MouseArea { id: mArea anchors.fill: parent cursorShape: root.readOnly ? Qt.ArrowCursor : (controlModifierEnabled ? Qt.SizeBDiagCursor : (pressed ? Qt.ClosedHandCursor : Qt.OpenHandCursor)) propagateComposedEvents: true property bool controlModifierEnabled: false onPositionChanged: function(mouse) { mArea.controlModifierEnabled = (mouse.modifiers & Qt.ControlModifier) mouse.accepted = false } acceptedButtons: Qt.LeftButton hoverEnabled: true drag.target: circle drag.onActiveChanged: { if (!drag.active) { root.moved(circle.x - (root.width - circle.width) / 2, circle.y - (root.height - circle.height) / 2) } } onPressed: { forceActiveFocus() } onWheel: function(wheel) { mArea.controlModifierEnabled = (wheel.modifiers & Qt.ControlModifier) if (wheel.modifiers & Qt.ControlModifier) { root.incrementRadius(wheel.angleDelta.y / 120.0) wheel.accepted = true } else { wheel.accepted = false } } } } } property alias circleRadius: circle.radius property alias circleBorder: circle.border /* // visualize top-left corner for debugging purpose Rectangle { color: "red" width: 500 height: 50 } Rectangle { color: "red" width: 50 height: 500 } */ } ================================================ FILE: meshroom/ui/qml/Viewer/ColorCheckerEntity.qml ================================================ import QtQuick Item { id: root // Required for perspective transform property real sizeX: 1675.0 // Might be overridden in ColorCheckerViewer property real sizeY: 1125.0 // Might be overridden in ColorCheckerViewer property var colors: null property var window: null Rectangle { id: canvas anchors.centerIn: parent width: parent.sizeX height: parent.sizeY color: "transparent" border.width: Math.max(1, (4.0 / zoom)) border.color: "red" transformOrigin: Item.TopLeft transform: Matrix4x4 { id: transformation matrix: Qt.matrix4x4() } } function applyTransform(m) { transformation.matrix = Qt.matrix4x4( m[0][0], m[0][1], 0, m[0][2], m[1][0], m[1][1], 0, m[1][2], 0, 0, 1, 0, m[2][0], m[2][1], 0, m[2][2]) } } ================================================ FILE: meshroom/ui/qml/Viewer/ColorCheckerPane.qml ================================================ import QtQuick import QtQuick.Layouts import Controls 1.0 FloatingPane { id: root property var colors: null clip: true padding: 4 anchors.rightMargin: 0 ColumnLayout { anchors.fill: parent Grid { id: grid spacing: 5 x: spacing y: spacing rows: 4 columns: 6 Repeater { model: root.colors Rectangle { id: cell width: root.width / grid.columns - grid.spacing * (grid.columns + 1) / grid.columns height: root.height / grid.rows - grid.spacing * (grid.rows + 1) / grid.rows color: Qt.rgba(modelData.r, modelData.g, modelData.b, 1.0) } } } } } ================================================ FILE: meshroom/ui/qml/Viewer/ColorCheckerViewer.qml ================================================ import QtQuick Item { id: root property url source: undefined property var json: null property var viewpoint: null property real zoom: 1.0 // Required for perspective transform // Match theoretical values in AliceVision // See https://github.com/alicevision/AliceVision/blob/68ab70bcbc3eb01b73dc8dea78c78d8b4778461c/src/software/utils/main_colorCheckerDetection.cpp#L47 readonly property real ccheckerSizeX: 1675.0 readonly property real ccheckerSizeY: 1125.0 property var ccheckers: [] property int selectedCChecker: -1 Component.onCompleted: { readSourceFile() } onSourceChanged: { readSourceFile() } onViewpointChanged: { loadCCheckers() } property var updatePane: null function getColors() { if (ccheckers[selectedCChecker] === undefined) return null if (ccheckers[selectedCChecker].colors === undefined) return null return ccheckers[selectedCChecker].colors } function readSourceFile() { var xhr = new XMLHttpRequest xhr.open("GET", root.source) xhr.onreadystatechange = function() { if (xhr.readyState === XMLHttpRequest.DONE && xhr.status == 200) { try { root.json = null root.json = JSON.parse(xhr.responseText) } catch(exc) { console.warn("Failed to parse ColorCheckerDetection JSON file: " + source) return } } loadCCheckers() } xhr.send() } function loadCCheckers() { emptyCCheckers() if (root.json === null) { return } var currentImagePath = (root.viewpoint && root.viewpoint.attribute && root.viewpoint.attribute.childAttribute("path")) ? root.viewpoint.attribute.childAttribute("path").value : null var viewId = (root.viewpoint && root.viewpoint.attribute && root.viewpoint.attribute.childAttribute("viewId")) ? root.viewpoint.attribute.childAttribute("viewId").value : null for (var i = 0; i < root.json.checkers.length; i++) { // Only load ccheckers for the current view var checker = root.json.checkers[i] if (checker.viewId === viewId || checker.imagePath === currentImagePath) { var cpt = Qt.createComponent("ColorCheckerEntity.qml") var obj = cpt.createObject(root, { x: ccheckerSizeX / 2, y: ccheckerSizeY / 2, sizeX: root.ccheckerSizeX, sizeY: root.ccheckerSizeY, colors: root.json.checkers[i].colors }) obj.applyTransform(root.json.checkers[i].transform) ccheckers.push(obj) selectedCChecker = ccheckers.length - 1 break } } updatePane() } function emptyCCheckers() { for (var i = 0; i < ccheckers.length; i++) ccheckers[i].destroy() ccheckers = [] selectedCChecker = -1 } } ================================================ FILE: meshroom/ui/qml/Viewer/FeaturesInfoOverlay.qml ================================================ import QtQuick import QtQuick.Controls import QtQuick.Layouts import Controls 1.0 import MaterialIcons 2.2 import Utils 1.0 /** * FeaturesInfoOverlay is an overlay that displays info and * provides controls over a FeaturesViewer component. */ FloatingPane { id: root property int pluginStatus: Loader.Null property Item featuresViewer: null property var mfeatures: null property var mtracks: null property var msfmdata: null property var featuresNodeName: "" property var tracksNodeName: "" property var sfmdataNodeName: "" ColumnLayout { // Header RowLayout { ColumnLayout { // Node used to read features Label { text: "Features Provider: " + featuresNodeName Layout.fillWidth: true } Label { text: "Tracks Provider: " + tracksNodeName Layout.fillWidth: true } Label { text: "SfMData Provider: " + sfmdataNodeName Layout.fillWidth: true } } // Settings menu Loader { Layout.alignment: Qt.AlignTop active: root.pluginStatus === Loader.Ready sourceComponent: MaterialToolButton { text: MaterialIcons.settings font.pointSize: 10 onClicked: settingsMenu.popup(width, 0) Menu { id: settingsMenu padding: 4 implicitWidth: 350 RowLayout { Label { text: "Feature Scale Filter:" } RangeSlider { id: featureScaleFilterRS ToolTip.text: "Filters features according to their scale (or filters tracks according to their average feature scale)." ToolTip.visible: hovered Layout.fillHeight: true Layout.alignment: Qt.AlignRight from: 0 to: 1 first.value: 0 first.onMoved: { root.featuresViewer.featureMinScaleFilter = Math.pow(first.value,4) } second.value: 1 second.onMoved: { root.featuresViewer.featureMaxScaleFilter = Math.pow(second.value,4) } stepSize: 0.01 } } RowLayout { Label { text: "Feature Display Mode:" } ComboBox { id: featureDisplayModeCB flat: true ToolTip.text: "Feature Display Mode:\n" + "* Points: Simple points.\n" + "* Square: Scaled filled squares.\n" + "* Oriented Square: Scaled and oriented squares." ToolTip.visible: hovered Layout.fillHeight: true Layout.alignment: Qt.AlignRight model: root.featuresViewer ? root.featuresViewer.featureDisplayModes : null currentIndex: root.featuresViewer ? root.featuresViewer.featureDisplayMode : 0 onActivated: root.featuresViewer.featureDisplayMode = currentIndex } } RowLayout { Label { text: "Track Display Mode:" } ComboBox { id: trackDisplayModeCB flat: true ToolTip.text: "Track Display Mode:\n" + "* Lines Only: Only track lines.\n" + "* Current Matches: Track lines with current matches/landmarks.\n" + "* All Matches: Track lines with all matches / landmarks." ToolTip.visible: hovered Layout.fillHeight: true Layout.alignment: Qt.AlignRight model: root.featuresViewer ? root.featuresViewer.trackDisplayModes : null currentIndex: root.featuresViewer ? root.featuresViewer.trackDisplayMode : 0 onActivated: root.featuresViewer.trackDisplayMode = currentIndex } } RowLayout { Label { text: "Track Contiguous Filter:" } CheckBox { id: trackContiguousFilterCB ToolTip.text: "Hides non-contiguous track parts." ToolTip.visible: hovered Layout.fillHeight: true Layout.alignment: Qt.AlignRight checked: root.featuresViewer ? root.featuresViewer.trackContiguousFilter : false onClicked: root.featuresViewer.trackContiguousFilter = trackContiguousFilterCB.checked } } RowLayout { Label { text: "Track Inliers Filter:" } CheckBox { id: trackInliersFilterCB ToolTip.text: "Hides tracks without at least one inlier." ToolTip.visible: hovered Layout.fillHeight: true Layout.alignment: Qt.AlignRight checked: root.featuresViewer ? root.featuresViewer.trackInliersFilter : false onClicked: root.featuresViewer.trackInliersFilter = trackInliersFilterCB.checked } } RowLayout { Label { text: "Display 3D Tracks:" } CheckBox { id: display3dTracksCB ToolTip.text: "Draws tracks between 3d points instead of 2d points (if possible)." ToolTip.visible: hovered Layout.fillHeight: true Layout.alignment: Qt.AlignRight checked: root.featuresViewer ? root.featuresViewer.display3dTracks : false onClicked: root.featuresViewer.display3dTracks = display3dTracksCB.checked } } RowLayout { Label { text: "Display Track Endpoints:" } CheckBox { id: displayTrackEndpointsCB ToolTip.text: "Draws markers indicating the global start/end point of each track." ToolTip.visible: hovered Layout.fillHeight: true Layout.alignment: Qt.AlignRight checked: root.featuresViewer ? root.featuresViewer.displayTrackEndpoints : false onClicked: root.featuresViewer.displayTrackEndpoints = displayTrackEndpointsCB.checked } } RowLayout { Label { text: "Time Window:" } SpinBox { id: timeWindowSB ToolTip.text: "Time Window: The number of frames to consider for tracks display.\n" + "e.g. With time window set at x, tracks will start at current frame - x and they will end at current frame + x." ToolTip.visible: hovered Layout.fillHeight: true Layout.alignment: Qt.AlignRight from: -1 to: 50 value: root.featuresViewer ? root.featuresViewer.timeWindow : 0 stepSize: 1 editable: true textFromValue: function(value, locale) { if (value === -1) return "No Limit" if (value === 0) return "Disable" return value } valueFromText: function(text, locale) { if (text === "No Limit") return -1 if (text === "Disable") return 0 return Number.fromLocaleString(locale, text) } onValueChanged: { if (root.featuresViewer) root.featuresViewer.timeWindow = timeWindowSB.value } } } } } } } // Error message if AliceVision plugin is unavailable Label { visible: root.pluginStatus === Loader.Error text: "AliceVision plugin is required to display features" color: Colors.red } // Feature types ListView { implicitHeight: contentHeight implicitWidth: contentItem.childrenRect.width model: root.featuresViewer !== null ? root.featuresViewer.model : 0 delegate: RowLayout { id: featureType property var viewer: root.featuresViewer.itemAt(index) spacing: 4 // Features visibility toggle MaterialToolButton { id: featuresVisibilityButton checkable: true checked: true text: MaterialIcons.center_focus_strong ToolTip.text: "Display Extracted Features" onClicked: { featureType.viewer.displayFeatures = featuresVisibilityButton.checked } font.pointSize: 10 opacity: featureType.viewer.visible ? 1.0 : 0.6 } // Tracks visibility toggle MaterialToolButton { id: tracksVisibilityButton checkable: true checked: false text: MaterialIcons.timeline ToolTip.text: "Display Tracks" onClicked: { featureType.viewer.displayTracks = tracksVisibilityButton.checked root.featuresViewer.enableTimeWindow = tracksVisibilityButton.checked } font.pointSize: 10 } // Matches visibility toggle MaterialToolButton { id: matchesVisibilityButton checkable: true checked: true text: MaterialIcons.sync ToolTip.text: "Display Matches" onClicked: { featureType.viewer.displayMatches = matchesVisibilityButton.checked } font.pointSize: 10 } // Landmarks visibility toggle MaterialToolButton { id: landmarksVisibilityButton checkable: true checked: true text: MaterialIcons.fiber_manual_record ToolTip.text: "Display Landmarks" onClicked: { featureType.viewer.displayLandmarks = landmarksVisibilityButton.checked } font.pointSize: 10 } // ColorChart picker ColorChart { implicitWidth: 12 implicitHeight: implicitWidth colors: root.featuresViewer.colors currentIndex: featureType.viewer.colorIndex // offset featuresViewer color set when changing the color of one feature type onColorPicked: function(colorIndex) { featureType.viewer.colorOffset = colorIndex - index } } // Feature type name Label { property string descType: featureType.viewer.describerType property int viewId: root.featuresViewer.currentViewId text: descType + ": " + ((root.mfeatures && root.mfeatures.status === MFeatures.Ready) ? root.mfeatures.nbFeatures(descType, viewId) : " - ") + " / " + ((root.mtracks && root.mtracks.status === MTracks.Ready) ? root.mtracks.nbMatches(descType, viewId) : " - ") + " / " + ((root.msfmdata && root.msfmdata.status === MSfMData.Ready) ? root.msfmdata.nbLandmarks(descType, viewId) : " - ") } // Feature loading status Loader { active: (root.mfeatures && root.mfeatures.status === MFeatures.Loading) sourceComponent: BusyIndicator { padding: 0 implicitWidth: 12 implicitHeight: 12 running: true } } } } } } ================================================ FILE: meshroom/ui/qml/Viewer/FeaturesViewer.qml ================================================ import QtQuick import AliceVision 1.0 as AliceVision import Utils 1.0 /** * FeaturesViewer displays the extracted feature points of a View. * Requires QtAliceVision plugin. */ Repeater { id: root /// Features property var features /// Tracks property var tracks /// SfMData property var sfmData /// The list of describer types to load property alias describerTypes: root.model /// List of available feature display modes readonly property var featureDisplayModes: ['Points', 'Squares', 'Oriented Squares'] /// Current feature display mode index property int featureDisplayMode: 2 /// List of available track display modes readonly property var trackDisplayModes: ['Lines Only', 'Current Matches', 'All Matches'] /// Current track display mode index property int trackDisplayMode: 1 // Minimum feature scale score to display property real featureMinScaleFilter: 0 // Maximum feature scale score to display property real featureMaxScaleFilter: 1 /// Display 3d tracks property bool display3dTracks: false /// Display only contiguous tracks property bool trackContiguousFilter: true /// Display only tracks with at least one inlier property bool trackInliersFilter: false /// Display track endpoints property bool displayTrackEndpoints: true /// The list of colors used for displaying several describers property var colors: [Colors.blue, Colors.green, Colors.yellow, Colors.cyan, Colors.pink, Colors.lime] //, Colors.orange, Colors.red /// Current view ID property var currentViewId property bool syncFeaturesSelected: false /// Time window property bool enableTimeWindow: false property int timeWindow: 1 model: root.describerTypes // Instantiate one FeaturesViewer by describer type delegate: AliceVision.FeaturesViewer { readonly property int colorIndex: (index + colorOffset) % root.colors.length property int colorOffset: 0 featureDisplayMode: root.featureDisplayMode trackDisplayMode: root.trackDisplayMode featureMinScaleFilter: root.featureMinScaleFilter featureMaxScaleFilter: root.featureMaxScaleFilter display3dTracks: root.display3dTracks trackContiguousFilter: root.trackContiguousFilter trackInliersFilter: root.trackInliersFilter displayTrackEndpoints: root.displayTrackEndpoints featureColor: root.colors[colorIndex] matchColor: Colors.orange landmarkColor: Colors.red describerType: modelData currentViewId: syncFeaturesSelected ? _currentScene.pickedViewId : root.currentViewId enableTimeWindow: root.enableTimeWindow timeWindow: root.timeWindow mfeatures: root.features mtracks: root.tracks msfmData: root.sfmData } } ================================================ FILE: meshroom/ui/qml/Viewer/FloatImage.qml ================================================ import QtQuick import AliceVision 1.0 as AliceVision import Utils 1.0 /** * FloatImage displays an Image with gamma / offset / channel controls * Requires QtAliceVision plugin. */ AliceVision.FloatImageViewer { id: root width: sourceSize.width height: sourceSize.height visible: true // paintedWidth / paintedHeight / imageStatus for compatibility with standard Image property int paintedWidth: sourceSize.width property int paintedHeight: sourceSize.height property var imageStatus: { if (root.status === AliceVision.FloatImageViewer.EStatus.LOADING) { return Image.Loading } else if (root.status === AliceVision.FloatImageViewer.EStatus.LOADING_ERROR || root.status === AliceVision.FloatImageViewer.EStatus.MISSING_FILE || root.status === AliceVision.FloatImageViewer.EStatus.OUTDATED_LOADING) { return Image.Error } else if ((root.source === "") || (root.sourceSize.height <= 0) || (root.sourceSize.width <= 0)) { return Image.Null } return Image.Ready } onStatusChanged: { if (viewerTypeString === "panorama") { var activeNode = _currentScene.activeNodes.get('SfMTransform').node } root.surface.setIdView(idView); } property string channelModeString : "rgba" channelMode: { switch (channelModeString) { case "rgb": return AliceVision.FloatImageViewer.EChannelMode.RGB case "r": return AliceVision.FloatImageViewer.EChannelMode.R case "g": return AliceVision.FloatImageViewer.EChannelMode.G case "b": return AliceVision.FloatImageViewer.EChannelMode.B case "a": return AliceVision.FloatImageViewer.EChannelMode.A default: return AliceVision.FloatImageViewer.EChannelMode.RGBA } } property string viewerTypeString : "hdr" surface.viewerType: { switch (viewerTypeString) { case "hdr": return AliceVision.Surface.EViewerType.HDR; case "distortion": return AliceVision.Surface.EViewerType.DISTORTION; case "panorama": return AliceVision.Surface.EViewerType.PANORAMA; default: return AliceVision.Surface.EViewerType.HDR; } } property int pointsNumber: (surface.subdivisions + 1) * (surface.subdivisions + 1) property int idView: 0; clearBeforeLoad: false property alias containsMouse: mouseArea.containsMouse property alias mouseX: mouseArea.mouseX property alias mouseY: mouseArea.mouseY MouseArea { id: mouseArea anchors.fill: parent hoverEnabled: true // Do not intercept mouse events, only get the mouse over information acceptedButtons: Qt.NoButton } function isMouseOver(mx, my) { return root.surface.isMouseInside(mx, my) } function getMouseCoordinates(mx, my) { if (isMouseOver(mx, my)) { root.surface.mouseOver = true return true } else { root.surface.mouseOver = false return false } } function onChangedHighlightState(isHighlightable) { if (!isHighlightable) root.surface.mouseOver = false } /* * Principal Point */ function updatePrincipalPoint() { var pp = root.surface.getPrincipalPoint() ppRect.x = pp.x ppRect.y = pp.y } property bool isPrincipalPointsDisplayed : false Item { id: principalPoint Rectangle { id: ppRect width: root.sourceSize.width/150; height: width radius : width/2 x: 0 y: 0 color: "red" visible: viewerTypeString === "distortion" && isPrincipalPointsDisplayed onVisibleChanged: { updatePrincipalPoint() } } } } ================================================ FILE: meshroom/ui/qml/Viewer/HdrImageToolbar.qml ================================================ import QtQuick import QtQuick.Controls import QtQuick.Layouts import Controls 1.0 import Utils 1.0 FloatingPane { id: root anchors.margins: 0 padding: 5 radius: 0 property real gainDefaultValue: 1.0 property real gammaDefaultValue: 1.0 property string pixelCoordinatesPlaceholder: "--" property real slidersPowerValue: 4.0 property real gainValue: Math.pow(gainCtrl.value, slidersPowerValue).toFixed(2) property real gammaValue: Math.pow(gammaCtrl.value, slidersPowerValue).toFixed(2) property alias channelModeValue: channelsCtrl.value property variant colorRGBA: null property variant mousePosition: ({x:0, y:0}) property bool colorPickerVisible: true property variant userDefinedXPixel: null property variant userDefinedYPixel: null background: Rectangle { color: root.palette.window } function resetDefaultValues() { gainCtrl.value = root.gainDefaultValue gammaCtrl.value = root.gammaDefaultValue } function resetPixelCoordinates() { if(userDefinedXPixel !== null) { userDefinedXPixel = null } if(userDefinedYPixel !== null) { userDefinedYPixel = null } } function toggleChannel(channelName, defaultChannel) { /* toggle channelBox to the given channelName. If the channel is already set, the defaultChannel is set */ if (!setChannel(channelName)) { setChannel(defaultChannel) } } function setChannel(channelName) { /* set the given channel in the combobox */ if (channelName === channelsCtrl.value) { return false } const channelIndex = channelsCtrl.channels.indexOf(channelName) if (channelIndex === -1 ) { return false } channelsCtrl.currentIndex = channelIndex return true } onMousePositionChanged: { resetPixelCoordinates() } DoubleValidator { id: doubleValidator locale: 'C' // Use '.' decimal separator disregarding of the system locale } RowLayout { id: toolLayout anchors.fill: parent // Channel mode ComboBox { id: channelsCtrl // Set min size to 4 characters + one margin for the combobox Layout.minimumWidth: 5.0 * Qt.application.font.pixelSize Layout.preferredWidth: Layout.minimumWidth flat: true property var channels: ["rgba", "rgb", "r", "g", "b","a"] property string value: channels[currentIndex] onValueChanged: { currentIndex = channels.indexOf(value) } model: channels } // Gain slider RowLayout { spacing: 5 ToolButton { text: "Gain" ToolTip.visible: ToolTip.text && hovered ToolTip.delay: 100 ToolTip.text: "Reset Gain" onClicked: { gainLabel.text = gainDefaultValue gainCtrl.value = gainLabel.text } } ExpressionTextField { id: gainLabel ToolTip.visible: ToolTip.text && hovered ToolTip.delay: 100 ToolTip.text: "Color Gain (in linear colorspace)" text: gainValue Layout.preferredWidth: textMetrics_gainValue.width selectByMouse: true onAccepted: { if (!gainLabel.hasExprError) { if (gainLabel.text <= 0) { gainLabel.evaluatedValue = 0 gainCtrl.value = gainLabel.evaluatedValue } else { gainCtrl.value = Math.pow(Number(gainLabel.evaluatedValue), 1.0 / slidersPowerValue) } } } } Slider { id: gainCtrl Layout.fillWidth: true from: 0.01 to: 2 value: gainDefaultValue stepSize: 0.01 onMoved: { gainLabel.text = Math.pow(value, slidersPowerValue).toFixed(2) } } } // Gamma slider RowLayout { spacing: 5 ToolButton { text: "γ" ToolTip.visible: ToolTip.text && hovered ToolTip.delay: 100 ToolTip.text: "Reset Gamma" onClicked: { gammaLabel.text = gammaDefaultValue gammaCtrl.value = gammaLabel.text } } ExpressionTextField { id: gammaLabel ToolTip.visible: ToolTip.text && hovered ToolTip.delay: 100 ToolTip.text: "Apply Gamma (after Gain and in linear colorspace)" text: gammaValue Layout.preferredWidth: textMetrics_gainValue.width selectByMouse: true onAccepted: { if (!gammaLabel.hasExprError) { if (gammaLabel.evaluatedValue <= 0) { gammaLabel.evaluatedValue = 0 gammaCtrl.value = gammaLabel.evaluatedValue } else { gammaCtrl.value = Math.pow(Number(gammaLabel.evaluatedValue), 1.0 / slidersPowerValue) } } } } Slider { id: gammaCtrl Layout.fillWidth: true from: 0.01 to: 2 value: gammaDefaultValue stepSize: 0.01 onMoved: { gammaLabel.text = Math.pow(value, slidersPowerValue).toFixed(2) } } } RowLayout { Label { text: "x" } TextField { id: xPixel text: root.mousePosition ? root.mousePosition.x : null Layout.preferredWidth: 40 placeholderText: pixelCoordinatesPlaceholder validator: IntValidator { bottom: 0 } onTextEdited: { const xPixelValue = parseInt(xPixel.text) userDefinedXPixel = Number.isNaN(xPixelValue) ? null : xPixelValue } } Label { text: "y" } TextField { id: yPixel text: root.mousePosition ? root.mousePosition.y : null Layout.preferredWidth: 40 placeholderText: pixelCoordinatesPlaceholder validator: IntValidator { bottom: 0 } onTextEdited: { const yPixelValue = parseInt(yPixel.text) userDefinedYPixel = Number.isNaN(yPixelValue) ? null : yPixelValue } } } Rectangle { visible: colorPickerVisible Layout.preferredWidth: 20 implicitWidth: 20 implicitHeight: parent.height color: root.colorRGBA ? Qt.rgba(red.value_gamma, green.value_gamma, blue.value_gamma, 1.0) : "black" } // RGBA colors RowLayout { spacing: 1 visible: colorPickerVisible TextField { id: red property real value: root.colorRGBA ? root.colorRGBA.x : 0.0 property real value_gamma: Math.pow(value, 1.0 / 2.2) text: root.colorRGBA ? value.toFixed(6) : "--" Layout.preferredWidth: textMetrics_colorValue.width selectByMouse: true validator: doubleValidator horizontalAlignment: TextInput.AlignLeft readOnly: true // autoScroll: When the text is too long, display the left part // (with the most important values and cut the floating point details) autoScroll: false Rectangle { anchors.verticalCenter: parent.bottom width: parent.width height: 3 color: Qt.rgba(red.value_gamma, 0.0, 0.0, 1.0) } } TextField { id: green property real value: root.colorRGBA ? root.colorRGBA.y : 0.0 property real value_gamma: Math.pow(value, 1.0/2.2) text: root.colorRGBA ? value.toFixed(6) : "--" Layout.preferredWidth: textMetrics_colorValue.width selectByMouse: true validator: doubleValidator horizontalAlignment: TextInput.AlignLeft readOnly: true // autoScroll: When the text is too long, display the left part // (with the most important values and cut the floating point details) autoScroll: false Rectangle { anchors.verticalCenter: parent.bottom width: parent.width height: 3 color: Qt.rgba(0.0, green.value_gamma, 0.0, 1.0) } } TextField { id: blue property real value: root.colorRGBA ? root.colorRGBA.z : 0.0 property real value_gamma: Math.pow(value, 1.0 / 2.2) text: root.colorRGBA ? value.toFixed(6) : "--" Layout.preferredWidth: textMetrics_colorValue.width selectByMouse: true validator: doubleValidator horizontalAlignment: TextInput.AlignLeft readOnly: true // autoScroll: When the text is too long, display the left part // (with the most important values and cut the floating point details) autoScroll: false Rectangle { anchors.verticalCenter: parent.bottom width: parent.width height: 3 color: Qt.rgba(0.0, 0.0, blue.value_gamma, 1.0) } } TextField { id: alpha property real value: root.colorRGBA ? root.colorRGBA.w : 0.0 property real value_gamma: Math.pow(value, 1.0 / 2.2) text: root.colorRGBA ? value.toFixed(6) : "--" Layout.preferredWidth: textMetrics_colorValue.width selectByMouse: true validator: doubleValidator horizontalAlignment: TextInput.AlignLeft readOnly: true // autoScroll: When the text is too long, display the left part // (with the most important values and cut the floating point details) autoScroll: false Rectangle { anchors.verticalCenter: parent.bottom width: parent.width height: 3 color: Qt.rgba(alpha.value_gamma, alpha.value_gamma, alpha.value_gamma, 1.0) } } } } TextMetrics { id: textMetrics_colorValue font: red.font text: "1.2345" // Use one more than expected to get the correct value (probably needed due to TextField margin) } TextMetrics { id: textMetrics_gainValue font: gainLabel.font text: "1.2345" } } ================================================ FILE: meshroom/ui/qml/Viewer/ImageMetadataView.qml ================================================ import QtQuick import QtQuick.Controls import QtQuick.Layouts import QtPositioning 6.6 import QtLocation 6.6 import Controls 1.0 import MaterialIcons 2.2 import Utils 1.0 /** * ImageMetadataView displays a JSON model representing an image's metadata as a ListView. */ FloatingPane { id: root property alias metadata: metadataModel.metadata property var coordinates: QtPositioning.coordinate() clip: true padding: 4 anchors.rightMargin: 0 /** * Convert GPS metadata to degree coordinates. * * GPS coordinates in metadata can be store in 3 forms: * (degrees), (degrees, minutes), (degrees, minutes, seconds) */ function gpsMetadataToCoordinates(value, ref) { var values = value.split(",") var result = 0 for (var i = 0; i < values.length; ++i) { // Divide each component by the corresponding power of 60 // 1 for degree, 60 for minutes, 3600 for seconds result += Number(values[i]) / Math.pow(60, i) } // Handle opposite reference: South (latitude) or West (longitude) return (ref === "S" || ref === "W") ? -result : result } /// Try to get GPS coordinates from metadata function getGPSCoordinates(metadata) { // GPS data available if (metadata && metadata["GPS:Longitude"] !== undefined && metadata["GPS:Latitude"] !== undefined) { var latitude = gpsMetadataToCoordinates(metadata["GPS:Latitude"], metadata["GPS:LatitudeRef"]) var longitude = gpsMetadataToCoordinates(metadata["GPS:Longitude"], metadata["GPS:LongitudeRef"]) var altitude = metadata["GPS:Altitude"] || 0 return QtPositioning.coordinate(latitude, longitude, altitude) } else { // GPS data unavailable: reset coordinates to default value return QtPositioning.coordinate() } } // Metadata model // Available roles for child items: // - group: metadata group if any, "-" otherwise // - key: metadata key // - value: metadata value // - raw: a sortable/filterable representation of the metadata as "group:key=value" ListModel { id: metadataModel property var metadata: ({}) // Reset model when metadata changes onMetadataChanged: { metadataModel.clear() var entries = [] // Prepare data to populate the model from the input metadata object for (var key in metadata) { var entry = {} // Split on ":" to get group and key var i = key.lastIndexOf(":") if (i === -1) { i = key.lastIndexOf("/") } if (i !== -1) { entry["group"] = key.substr(0, i) entry["key"] = key.substr(i+1) } else { // Set default group to something convenient for sorting entry["group"] = "-" entry["key"] = key } // If a key has an empty corresponding value, set it as an empty string. // Otherwise it will be considered as a Variant Map. if (typeof(metadata[key]) != "string") entry["value"] = "" else entry["value"] = metadata[key] entry["raw"] = entry["group"] + ":" + entry["key"] + "=" + entry["value"] entries.push(entry) } // Reset the model with prepared data (limit to one update event) metadataModel.append(entries) coordinates = getGPSCoordinates(metadata) } } // Background WheelEvent grabber MouseArea { anchors.fill: parent acceptedButtons: Qt.MiddleButton onWheel: function(wheel) { wheel.accepted = true } } // Main Layout ColumnLayout { anchors.fill: parent SearchBar { id: searchBar Layout.fillWidth: true } RowLayout { Layout.alignment: Qt.AlignHCenter Label { font.family: MaterialIcons.fontFamily text: MaterialIcons.shutter_speed } Label { id: exposureLabel text: { if (metadata["ExposureTime"] === undefined) return "" var expStr = metadata["ExposureTime"] var exp = parseFloat(expStr) if (exp < 1.0) { var invExp = 1.0 / exp return "1/" + invExp.toFixed(0) } return expStr } elide: Text.ElideRight horizontalAlignment: Text.AlignHLeft } Item { width: 4 } Label { font.family: MaterialIcons.fontFamily text: MaterialIcons.camera } Label { id: fnumberLabel text: (metadata["FNumber"] !== undefined) ? ("f/" + metadata["FNumber"]) : "" elide: Text.ElideRight horizontalAlignment: Text.AlignHLeft } Item { width: 4 } Label { font.family: MaterialIcons.fontFamily text: MaterialIcons.iso } Label { id: isoLabel text: metadata["Exif:ISOSpeedRatings"] || "" elide: Text.ElideRight horizontalAlignment: Text.AlignHLeft } } // Metadata ListView ListView { id: metadataView Layout.fillWidth: true Layout.fillHeight: true spacing: 3 clip: true // SortFilter delegate over the metadataModel model: SortFilterDelegateModel { id: sortedMetadataModel model: metadataModel sortRole: "raw" filters: [{role: "raw", value: searchBar.text}] delegate: RowLayout { width: ListView.view.width Label { text: key leftPadding: 6 rightPadding: 4 Layout.preferredWidth: sizeHandle.x elide: Text.ElideRight } Label { text: value != undefined ? value : "" Layout.fillWidth: true wrapMode: Label.WrapAtWordBoundaryOrAnywhere } } } // Categories resize handle Rectangle { id: sizeHandle height: parent.contentHeight width: 1 color: root.palette.mid x: parent.width * 0.4 MouseArea { anchors.fill: parent anchors.margins: -4 cursorShape: Qt.SizeHorCursor drag { target: parent axis: Drag.XAxis threshold: 0 minimumX: metadataView.width * 0.2 maximumX: metadataView.width * 0.8 } } } // Display section based on metadata group section.property: "group" section.delegate: Pane { width: parent.width padding: 3 background: null Label { width: parent.width padding: 2 background: Rectangle { color: parent.palette.mid } text: section } } ScrollBar.vertical: ScrollBar{} } // Display map if GPS coordinates are available Loader { Layout.fillWidth: true Layout.preferredHeight: coordinates.isValid ? 160 : 0 active: coordinates.isValid Plugin { id: osmPlugin name: "osm" } sourceComponent: Map { id: map plugin: osmPlugin center: coordinates function recenter() { center = coordinates } Connections { target: root function onCoordinatesChanged() { recenter() } } zoomLevel: 16 // Coordinates visual indicator MapQuickItem { coordinate: coordinates anchorPoint.x: circle.paintedWidth / 2 anchorPoint.y: circle.paintedHeight sourceItem: Text { id: circle color: root.palette.highlight font.pointSize: 18 font.family: MaterialIcons.fontFamily text: MaterialIcons.location_on } } // Reset map center FloatingPane { anchors.right: parent.right anchors.top: parent.top padding: 2 visible: map.center !== coordinates ToolButton { font.family: MaterialIcons.fontFamily text: MaterialIcons.my_location ToolTip.visible: hovered ToolTip.text: "Recenter" padding: 0 onClicked: recenter() } } } } } } ================================================ FILE: meshroom/ui/qml/Viewer/LensDistortionToolbar.qml ================================================ import QtQuick import QtQuick.Controls import QtQuick.Layouts import Controls 1.0 import MaterialIcons 2.2 import Utils 1.0 FloatingPane { id: root anchors.margins: 0 padding: 5 radius: 0 property int opacityDefaultValue: 70 property int subdivisionsDefaultValue: 12 property int opacityValue: Math.pow(opacityCtrl.value, 1) property int subdivisionsValue: subdivisionsCtrl.value property variant colorRGBA: null property bool displayGrid: displayGridButton.checked property bool displayPrincipalPoint: displayPrincipalPointButton.checked property var colors: [Colors.lightgrey, Colors.grey, Colors.red, Colors.green, Colors.blue, Colors.yellow] readonly property int colorIndex: (colorOffset) % root.colors.length property int colorOffset: 0 property color color: root.colors[gridColorPicker.currentIndex] background: Rectangle { color: root.palette.window } DoubleValidator { id: doubleValidator locale: 'C' // Use '.' decimal separator disregarding of the system locale } RowLayout { id: toolLayout anchors.fill: parent MaterialToolButton { id: displayPrincipalPointButton ToolTip.text: "Display Principal Point" text: MaterialIcons.control_point font.pointSize: 13 padding: 5 Layout.minimumWidth: 0 checkable: true checked: false } MaterialToolButton { id: displayGridButton ToolTip.text: "Display Grid" text: MaterialIcons.grid_on font.pointSize: 13 padding: 5 Layout.minimumWidth: 0 checkable: true checked: true } ColorChart { id : gridColorPicker padding : 10 colors: root.colors currentIndex: root.colorIndex onColorPicked: function(colorIndex) { root.colorOffset = colorIndex } } // Grid opacity slider RowLayout { spacing: 5 ToolButton { text: "Grid Opacity" ToolTip.visible: ToolTip.text && hovered ToolTip.delay: 100 ToolTip.text: "Reset Opacity" onClicked: { opacityCtrl.value = opacityDefaultValue } } TextField { id: opacityLabel ToolTip.visible: ToolTip.text && hovered ToolTip.delay: 100 ToolTip.text: "Grid opacity" text: opacityValue.toFixed(1) horizontalAlignment: "AlignHCenter" Layout.preferredWidth: textMetrics_opacityValue.width selectByMouse: true validator: doubleValidator onAccepted: { opacityCtrl.value = Number(opacityLabel.text) } } Slider { id: opacityCtrl Layout.fillWidth: false from: 0 to: 100 value: opacityDefaultValue stepSize: 1 } } // Grid subdivisions slider RowLayout { spacing: 5 ToolButton { text: "Subdivisions" ToolTip.visible: ToolTip.text && hovered ToolTip.delay: 100 ToolTip.text: "Reset Subdivisions" onClicked: { subdivisionsCtrl.value = subdivisionsDefaultValue } } TextField { id: subdivisionsLabel ToolTip.visible: ToolTip.text && hovered ToolTip.delay: 100 ToolTip.text: "subdivisions" text: subdivisionsValue.toFixed(1) horizontalAlignment: "AlignHCenter" Layout.preferredWidth: textMetrics_subdivisionsValue.width selectByMouse: true validator: doubleValidator onAccepted: { subdivisionsCtrl.value = Number(subdivisionsLabel.text) } } Slider { id: subdivisionsCtrl Layout.fillWidth: false from: 2 to: 40 value: subdivisionsDefaultValue stepSize: 5 } } // Fill rectangle to have a better UI Rectangle { color: root.palette.window Layout.fillWidth: true } } TextMetrics { id: textMetrics_opacityValue font: opacityLabel.font text: "100.000" } TextMetrics { id: textMetrics_subdivisionsValue font: opacityLabel.font text: "100.00" } } ================================================ FILE: meshroom/ui/qml/Viewer/MFeatures.qml ================================================ import QtQuick import AliceVision 1.0 as AliceVision // Data from the View / Features. AliceVision.MFeatures { id: root } ================================================ FILE: meshroom/ui/qml/Viewer/MSfMData.qml ================================================ import QtQuick import AliceVision 1.0 as AliceVision // Data from the SfM AliceVision.MSfMData { id: root } ================================================ FILE: meshroom/ui/qml/Viewer/MTracks.qml ================================================ import QtQuick import AliceVision 1.0 as AliceVision AliceVision.MTracks { id: root } ================================================ FILE: meshroom/ui/qml/Viewer/PanoramaToolbar.qml ================================================ import QtQuick import QtQuick.Controls import QtQuick.Layouts import Controls 1.0 import MaterialIcons 2.2 import Utils 1.0 FloatingPane { id: root anchors.margins: 0 padding: 5 radius: 0 property bool enableEdit: enablePanoramaEdit.checked property bool enableHover: enableHover.checked property bool displayGrid: displayGrid.checked property int downscaleValue: downscaleSpinBox.value property int downscaleDefaultValue: 4 property int subdivisionsDefaultValue: 24 property int subdivisionsValue: subdivisionsCtrl.value property int mouseSpeed: speedSpinBox.value background: Rectangle { color: root.palette.window } function updateDownscaleValue(level) { downscaleSpinBox.value = level } DoubleValidator { id: doubleValidator locale: 'C' // Use '.' decimal separator disregarding of the system locale } RowLayout { id: toolLayout anchors.fill: parent MaterialToolButton { id: enablePanoramaEdit ToolTip.text: "Enable Panorama edition" text: MaterialIcons.open_with font.pointSize: 14 padding: 5 Layout.minimumWidth: 0 checkable: true checked: true } MaterialToolButton { id: enableHover ToolTip.text: "Enable hovering highlight" text: MaterialIcons.highlight font.pointSize: 14 padding: 5 Layout.minimumWidth: 0 checkable: true checked: true } MaterialToolButton { id: displayGrid ToolTip.text: "Display grid" text: MaterialIcons.grid_on font.pointSize: 14 padding: 5 Layout.minimumWidth: 0 checkable: true checked: true } RowLayout { spacing: 5 ToolButton { text: "Subdivisions" ToolTip.visible: ToolTip.text && hovered ToolTip.delay: 100 ToolTip.text: "Reset Subdivisions" onClicked: { subdivisionsCtrl.value = subdivisionsDefaultValue } } TextField { id: subdivisionsLabel ToolTip.visible: ToolTip.text && hovered ToolTip.delay: 100 ToolTip.text: "subdivisions" text: subdivisionsValue.toFixed(1) Layout.preferredWidth: textMetrics_subdivisionsValue.width selectByMouse: true validator: doubleValidator onAccepted: { subdivisionsCtrl.value = Number(subdivisionsLabel.text) } } Slider { id: subdivisionsCtrl Layout.fillWidth: false from: 2 to: 72 value: subdivisionsDefaultValue stepSize: 2 } } Rectangle{ color: root.palette.window Layout.fillWidth: true } RowLayout{ ToolButton { text: "Edit Speed" ToolTip.visible: ToolTip.text && hovered ToolTip.delay: 100 ToolTip.text: "Reset the mouse multiplier" onClicked: { speedSpinBox.value = 1 } } SpinBox { id: speedSpinBox from: 1 value: 1 to: 10 stepSize: 1 Layout.fillWidth: false Layout.maximumWidth: 50 validator: DoubleValidator { bottom: Math.min(speedSpinBox.from, speedSpinBox.to) top: Math.max(speedSpinBox.from, speedSpinBox.to) } textFromValue: function(value, locale) { return "x" + value.toString() } } } RowLayout{ ToolButton { text: "Downscale" ToolTip.visible: ToolTip.text && hovered ToolTip.delay: 100 ToolTip.text: "Reset the downscale" onClicked: { downscaleSpinBox.value = downscaleDefaultValue } } SpinBox { id: downscaleSpinBox from: 0 value: downscaleDefaultValue to: 5 stepSize: 1 Layout.fillWidth: false Layout.maximumWidth: 50 validator: DoubleValidator { bottom: Math.min(downscaleSpinBox.from, downscaleSpinBox.to) top: Math.max(downscaleSpinBox.from, downscaleSpinBox.to) } textFromValue: function(value, locale) { if (value === 0){ return 1 } else { return "1/" + Math.pow(2, value).toString() } } } } } TextMetrics { id: textMetrics_subdivisionsValue font: subdivisionsLabel.font text: "100.00" } } ================================================ FILE: meshroom/ui/qml/Viewer/PanoramaViewer.qml ================================================ import QtQuick import AliceVision 1.0 as AliceVision import Utils 1.0 /** * PanoramaViwer displays a list of Float Images * Requires QtAliceVision plugin. */ AliceVision.PanoramaViewer { id: root width: 3000 height: 1500 visible: (status === Image.Ready) // paintedWidth / paintedHeight / status for compatibility with standard Image property int paintedWidth: sourceSize.width property int paintedHeight: sourceSize.height property var status: { if (readyToLoad === Image.Ready) { return Image.Ready } else { return Image.Null } } property int readyToLoad: Image.Null property int subdivisionsPano: 12 property bool isEditable: true property bool isHighlightable: true property bool displayGridPano: true property int mouseMultiplier: 1 property bool cropFisheyePano: false property int idSelected : -1 onIsHighlightableChanged: { for (var i = 0; i < repeater.model; ++i) { repeater.itemAt(i).item.onChangedHighlightState(isHighlightable) } } property alias containsMouse: mouseAreaPano.containsMouse property bool isRotating: false property double lastX : 0 property double lastY: 0 property double xStart : 0 property double yStart : 0 property double previous_yaw: 0; property double previous_pitch: 0; property double previous_roll: 0; property double yaw: 0; property double pitch: 0; property double roll: 0; property var activeNode: _currentScene.activeNodes.get('SfMTransform').node // Yaw and Pitch in Degrees from SfMTransform node sliders property double yawNode: activeNode ? activeNode.attribute("manualTransform.manualRotation.y").value : 0 property double pitchNode: activeNode ? activeNode.attribute("manualTransform.manualRotation.x").value : 0 property double rollNode: activeNode ? activeNode.attribute("manualTransform.manualRotation.z").value : 0 // Convert angle functions function toDegrees(radians) { return radians * (180 / Math.PI) } function toRadians(degrees) { return degrees * (Math.PI / 180) } function fmod(a,b) { return Number((a - (Math.floor(a / b) * b)).toPrecision(8)) } // Limit angle between -180 and 180 function limitAngle(angle) { if (angle > 180) angle = -180.0 + (angle - 180.0) if (angle < -180) angle = 180.0 - (Math.abs(angle) - 180) return angle } function limitPitch(angle) { return (angle > 180 || angle < -180) ? root.pitch : angle } onYawNodeChanged: { root.yaw = yawNode } onPitchNodeChanged: { root.pitch = pitchNode } onRollNodeChanged: { root.roll = rollNode } Item { id: containerPanorama z: 10 Rectangle { width: 3000 height: 1500 color: "transparent" MouseArea { id: mouseAreaPano anchors.fill: parent hoverEnabled: true enabled: allImagesLoaded cursorShape: { if (isEditable) isRotating ? Qt.ClosedHandCursor : Qt.OpenHandCursor } onPositionChanged: function(mouse) { // Send Mouse Coordinates to Float Images Viewers idSelected = -1 for (var i = 0; i < repeater.model && isHighlightable; ++i) { var highlight = repeater.itemAt(i).item.getMouseCoordinates(mouse.x, mouse.y) repeater.itemAt(i).z = highlight ? 2 : 0 if (highlight) { idSelected = root.msfmData.viewsIds[i] } } // Rotate Panorama if (isRotating && isEditable) { var nx = Math.min(width - 1, mouse.x) var ny = Math.min(height - 1, mouse.y) var xoffset = nx - lastX; var yoffset = ny - lastY; if (xoffset != 0 || yoffset !=0) { var latitude_start = (yStart / height) * Math.PI - (Math.PI / 2); var longitude_start = ((xStart / width) * 2 * Math.PI) - Math.PI; var latitude_end = (ny / height) * Math.PI - ( Math.PI / 2); var longitude_end = ((nx / width) * 2 * Math.PI) - Math.PI; var start_pt = Qt.vector2d(latitude_start, longitude_start) var end_pt = Qt.vector2d(latitude_end, longitude_end) var previous_euler = Qt.vector3d(previous_yaw, previous_pitch, previous_roll) var result if (mouse.modifiers & Qt.ControlModifier) { result = Transformations3DHelper.updatePanoramaInPlane(previous_euler, start_pt, end_pt) root.pitch = result.x root.yaw = result.y root.roll = result.z } else { result = Transformations3DHelper.updatePanorama(previous_euler, start_pt, end_pt) root.pitch = result.x root.yaw = result.y root.roll = result.z } } _currentScene.setAttribute(activeNode.attribute("manualTransform.manualRotation.x"), Math.round(root.pitch)) _currentScene.setAttribute(activeNode.attribute("manualTransform.manualRotation.y"), Math.round(root.yaw)) _currentScene.setAttribute(activeNode.attribute("manualTransform.manualRotation.z"), Math.round(root.roll)) } } onPressed: function(mouse) { _currentScene.beginModification("Panorama Manual Rotation") isRotating = true lastX = mouse.x lastY = mouse.y xStart = mouse.x yStart = mouse.y previous_yaw = yaw previous_pitch = pitch previous_roll = roll } onReleased: function(mouse) { _currentScene.endModification() isRotating = false lastX = 0 lastY = 0 // Select the image in the image gallery if clicked if (xStart == mouse.x && yStart == mouse.y && idSelected != -1) { _currentScene.selectedViewId = idSelected } } } // Grid Panorama Viewer Canvas { id: gridPano visible: displayGridPano anchors.fill : parent property int wgrid: 40 onPaint: { var ctx = getContext("2d") ctx.lineWidth = 1.0 ctx.shadowBlur = 0 ctx.strokeStyle = "grey" var nrows = height / wgrid for (var i = 0; i < nrows + 1; ++i) { ctx.moveTo(0, wgrid * i) ctx.lineTo(width, wgrid * i) } var ncols = width / wgrid for (var j = 0; j < ncols + 1; ++j) { ctx.moveTo(wgrid * j, 0) ctx.lineTo(wgrid * j, height) } ctx.closePath() ctx.stroke() } } } } property int imagesLoaded: 0 property bool allImagesLoaded: false function loadRepeaterImages(index) { if (index < repeater.model) repeater.itemAt(index).loadItem() else allImagesLoaded = true } Item { id: panoImages width: root.width height: root.height Component { id: imgPano Loader { id: floatOneLoader active: root.readyToLoad visible: (floatOneLoader.status === Loader.Ready) z: 0 property bool imageLoaded: false property bool loading: false onImageLoadedChanged: { imagesLoaded++ loadRepeaterImages(imagesLoaded) } function loadItem() { if (!active) return if (loading) { loadRepeaterImages(index + 1) return } loading = true var idViewItem = msfmData.viewsIds[index] var sourceItem = Filepath.stringToUrl(msfmData.getUrlFromViewId(idViewItem)) setSource("FloatImage.qml", { "surface.viewerType": AliceVision.Surface.EViewerType.PANORAMA, "viewerTypeString": "panorama", "surface.subdivisions": Qt.binding(function() { return subdivisionsPano }), "cropFisheye" : Qt.binding(function(){ return cropFisheyePano }), "surface.pitch": Qt.binding(function() { return root.pitch }), "surface.yaw": Qt.binding(function() { return root.yaw }), "surface.roll": Qt.binding(function() { return root.roll }), "idView": Qt.binding(function() { return idViewItem }), "gamma": Qt.binding(function() { return hdrImageToolbar.gammaValue }), "gain": Qt.binding(function() { return hdrImageToolbar.gainValue }), "channelModeString": Qt.binding(function() { return hdrImageToolbar.channelModeValue }), "downscaleLevel": Qt.binding(function() { return downscale }), "source": Qt.binding(function() { return sourceItem }), "surface.msfmData": Qt.binding(function() { return root.msfmData }), "canBeHovered": true, "useSequence": false }) imageLoaded = Qt.binding(function() { return repeater.itemAt(index).item.imageStatus === Image.Ready ? true : false }) } } } Repeater { id: repeater model: 0 delegate: imgPano } Connections { target: root function onDownscaleReady() { root.imagesLoaded = 0 // Retrieve downscale value from C++ panoramaViewerToolbar.updateDownscaleValue(root.downscale) // Changing the repeater model (number of elements) panoImages.updateRepeater() root.readyToLoad = Image.Ready // Load images two by two loadRepeaterImages(0) loadRepeaterImages(1) } } function updateRepeater() { if (repeater.model !== root.msfmData.viewsIds.length) { repeater.model = 0 } repeater.model = root.msfmData.viewsIds.length } } } ================================================ FILE: meshroom/ui/qml/Viewer/PhongImageViewer.qml ================================================ import QtQuick import Utils 1.0 import AliceVision 1.0 as AliceVision /** * PhongImageViewer displays an Image (albedo + normal) with a given light direction. * Shading is done using Blinn-Phong reflection model, material and light direction parameters available. * Accept HdrImageToolbar controls (gamma / offset / channel). * * Requires QtAliceVision plugin. */ AliceVision.PhongImageViewer { id: root width: sourceSize.width height: sourceSize.height visible: true // paintedWidth / paintedHeight / imageStatus for compatibility with standard Image property int paintedWidth: sourceSize.width property int paintedHeight: sourceSize.height property var imageStatus: { if (root.status === AliceVision.PhongImageViewer.EStatus.LOADING) { return Image.Loading } else if (root.status === AliceVision.PhongImageViewer.EStatus.LOADING_ERROR || root.status === AliceVision.PhongImageViewer.EStatus.MISSING_FILE) { return Image.Error } else if ((root.sourcePath === "") || (root.sourceSize.height <= 0) || (root.sourceSize.width <= 0)) { return Image.Null } return Image.Ready } property string channelModeString : "rgba" channelMode: { switch (channelModeString) { case "rgb": return AliceVision.PhongImageViewer.EChannelMode.RGB case "r": return AliceVision.PhongImageViewer.EChannelMode.R case "g": return AliceVision.PhongImageViewer.EChannelMode.G case "b": return AliceVision.PhongImageViewer.EChannelMode.B case "a": return AliceVision.PhongImageViewer.EChannelMode.A default: return AliceVision.PhongImageViewer.EChannelMode.RGBA } } } ================================================ FILE: meshroom/ui/qml/Viewer/PhongImageViewerToolbar.qml ================================================ import QtQuick import QtQuick.Controls import QtQuick.Layouts import QtQuick.Dialogs import MaterialIcons 2.2 import Controls 1.0 import Utils 1.0 FloatingPane { id: root property color baseColorValue: colorText.text property real textureOpacityValue: textureTF.text property real kaValue: ambientTF.text property real kdValue: diffuseTF.text property real ksValue: specularTF.text property real shininessValue: shininessTF.text property bool displayLightController: true function reset () { colorText.text = "#333333" textureCtrl.value = 1.0 ambientCtrl.value = 0.0 diffuseCtrl.value = 1.0 specularCtrl.value = 0.5 shininessCtrl.value = 20.0 } anchors.margins: 0 padding: 5 radius: 0 ColumnLayout { id: phongLightingParameters anchors.fill: parent spacing: 5 // header RowLayout { // pane title Label { text: _currentScene && _currentScene.activeNodes.get("PhotometricStereo").node ? _currentScene.activeNodes.get("PhotometricStereo").node.label : "" font.bold: true Layout.fillWidth: true } // minimize or maximize button MaterialToolButton { id: bodyButton text: phongLightingToolbarBody.visible ? MaterialIcons.arrow_drop_down : MaterialIcons.arrow_drop_up font.pointSize: 10 ToolTip.text: phongLightingToolbarBody.visible ? "Minimize" : "Maximize" onClicked: { phongLightingToolbarBody.visible = !phongLightingToolbarBody.visible } } // reset button MaterialToolButton { id: resetButton text: MaterialIcons.refresh font.pointSize: 10 ToolTip.text: "Reset" onClicked: reset() } // settings menu MaterialToolButton { text: MaterialIcons.settings font.pointSize: 10 onClicked: settingsMenu.popup(width, 0) Menu { id: settingsMenu padding: 4 implicitWidth: 250 RowLayout { Label { text: "Display Directional Light Contoller:" } CheckBox { id: displayLightControllerCB ToolTip.text: "Hides directional light controller." ToolTip.visible: hovered Layout.fillHeight: true Layout.alignment: Qt.AlignRight checked: root.displayLightController onClicked: root.displayLightController = displayLightControllerCB.checked } } } } } // body GridLayout { id: phongLightingToolbarBody columns: 3 rowSpacing: 2 columnSpacing: 8 // base color Label { text: "Base Color" } Rectangle { height: colorText.height * 0.8 color: colorText.text Layout.alignment: Qt.AlignVCenter | Qt.AlignRight Layout.preferredWidth: textMetricsNormValue.width MouseArea { anchors.fill: parent onClicked: colorDialog.open() } } TextField { id: colorText text: "#333333" selectByMouse: true Layout.alignment: Qt.AlignLeft Layout.fillWidth: true } ColorDialog { id: colorDialog title: "Please choose a color" options: ColorDialog.NoEyeDropperButton selectedColor: colorText.text onAccepted: { colorText.text = selectedColor colorText.editingFinished() // artificially trigger change of attribute value close() } onRejected: close() } // texture opacity Label { text: "Texture" } TextField { id: textureTF text: textureCtrl.value.toFixed(2) selectByMouse: true horizontalAlignment: TextInput.AlignRight validator: doubleNormalizedValidator onEditingFinished: { textureCtrl.value = textureTF.text } ToolTip.text: "Texture Opacity." ToolTip.visible: hovered Layout.preferredWidth: textMetricsNormValue.width } Slider { id: textureCtrl from: 0.0 to: 1.0 value: 1.0 stepSize: 0.01 Layout.fillWidth: true } // diffuse (kd) Label { text: "Diffuse" } TextField { id: diffuseTF text: diffuseCtrl.value.toFixed(2) selectByMouse: true horizontalAlignment: TextInput.AlignRight validator: doubleNormalizedValidator onEditingFinished: { diffuseCtrl.value = diffuseTF.text } ToolTip.text: "Diffuse reflection (kd)." ToolTip.visible: hovered Layout.preferredWidth: textMetricsNormValue.width } Slider { id: diffuseCtrl from: 0.0 to: 1.0 value: 1.0 stepSize: 0.01 Layout.fillWidth: true } // ambient (ka) Label { text: "Ambient" } TextField { id: ambientTF text: ambientCtrl.value.toFixed(2) selectByMouse: true horizontalAlignment: TextInput.AlignRight validator: doubleNormalizedValidator onEditingFinished: { ambientCtrl.value = ambientTF.text } ToolTip.text: "Ambient reflection (ka)." ToolTip.visible: hovered Layout.preferredWidth: textMetricsNormValue.width } Slider { id: ambientCtrl from: 0.0 to: 1.0 value: 0.0 stepSize: 0.01 Layout.fillWidth: true } // specular (ks) Label { text: "Specular" } TextField { id: specularTF text: specularCtrl.value.toFixed(2) selectByMouse: true horizontalAlignment: TextInput.AlignRight validator: doubleNormalizedValidator onEditingFinished: { specularCtrl.value = specularTF.text } ToolTip.text: "Specular reflection (ks)." ToolTip.visible: hovered Layout.preferredWidth: textMetricsNormValue.width } Slider { id: specularCtrl from: 0.0 to: 1.0 value: 0.5 stepSize: 0.01 Layout.fillWidth: true } // shininess Label { text: "Shininess" } TextField { id: shininessTF text: shininessCtrl.value selectByMouse: true horizontalAlignment: TextInput.AlignRight validator: intShininessValidator onEditingFinished: { shininessCtrl.value = shininessTF.text } ToolTip.text: "Shininess constant." ToolTip.visible: hovered Layout.preferredWidth: textMetricsNormValue.width } Slider { id: shininessCtrl from: 1 to: 128 value: 20 stepSize: 1 Layout.fillWidth: true } } } DoubleValidator { id: doubleNormalizedValidator locale: 'C' // use '.' decimal separator disregarding of the system locale bottom: 0.0 top: 1.0 } IntValidator { id: intShininessValidator bottom: 1 top: 128 } TextMetrics { id: textMetricsNormValue font: ambientTF.font text: "1.2345" } } ================================================ FILE: meshroom/ui/qml/Viewer/SequencePlayer.qml ================================================ import QtCore import QtQuick import QtQuick.Controls import QtQuick.Layouts import Controls 1.0 import MaterialIcons 2.2 import Utils 1.0 /** * The Sequence Player is a UI for manipulating * the currently selected (and displayed) viewpoint * in an ordered set of viewpoints (i.e. a sequence). * * The viewpoint manipulation process can be manual * (for example by dragging a slider to change the current frame) * or automatic * (by playing the sequence, i.e. incrementing the current frame at a given time rate). */ FloatingPane { id: root // Exposed properties property var sortedViewIds: [] property var viewer: null property bool isOutputSequence: false readonly property alias sync3DSelected: m.sync3DSelected readonly property alias syncFeaturesSelected: m.syncFeaturesSelected property bool loading: fetchButton.checked || m.playing property alias settings_SequencePlayer: settings_SequencePlayer property alias frameId: m.frame property var frameRange: { "min" : 0, "max" : 0 } Settings { id: settings_SequencePlayer property int maxCacheMemory: viewer && viewer.ramInfo != undefined ? viewer.ramInfo.x / 4 : 0 } function updateSceneView() { if (isOutputSequence) return if (_currentScene && m.frame >= frameRange.min && m.frame < frameRange.max + 1) { if (!m.playing && !frameSlider.pressed) { _currentScene.selectedViewId = sortedViewIds[m.frame] } else { _currentScene.pickedViewId = sortedViewIds[m.frame] if (m.sync3DSelected) { _currentScene.updateSelectedViewpoint(_currentScene.pickedViewId) } } } } onIsOutputSequenceChanged: { if (!isOutputSequence) { frameId = frameRange.min } } onSortedViewIdsChanged: { frameSlider.from = frameRange.min frameSlider.to = frameRange.max } // Sequence player model: // - current frame // - data related to automatic sequence playing QtObject { id: m property int frame: frameRange.min property bool syncFeaturesSelected: true property bool sync3DSelected: true property bool playing: false property bool repeat: false property real fps: 24 onFrameChanged: { updateSceneView() } onPlayingChanged: { if (!playing) { updateSceneView() } else if (playing && (frame + 1 >= frameRange.max + 1)) { frame = frameRange.min } viewer.playback(playing) } } // Update the frame property // when the selected view ID is changed externally Connections { target: _currentScene function onSelectedViewIdChanged() { for (let idx = 0; idx < sortedViewIds.length; idx++) { if (_currentScene.selectedViewId === sortedViewIds[idx] && (m.frame != idx)) { m.frame = idx } } } } // In play mode // we use a timer to increment the frame property // at a given time rate (defined by the fps property) Timer { id: timer repeat: true running: m.playing && root.visible interval: 1000 / m.fps onTriggered: { if (viewer.imageStatus !== Image.Ready) { // Wait for current image to be displayed before switching to next image return } let nextIndex = m.frame + 1 if (nextIndex == frameRange.max + 1) { if (m.repeat) { m.frame = frameRange.min return } else { m.playing = false return } } m.frame = nextIndex } } // Widgets: // - "Previous Frame" button // - "Play - Pause" button // - "Next Frame" button // - frame label // - frame slider // - FPS spin box // - "Repeat" button RowLayout { anchors.fill: parent IntSelector { id: frameInput tooltipText: "Frame" displayButtons: true range: frameRange onValueChanged: { m.frame = value } Binding { target: frameInput property: "value" value: m.frame when: !frameInput.activeFocus } } MaterialToolButton { id: playButton checkable: true checked: false text: checked ? MaterialIcons.pause_circle : MaterialIcons.play_circle font.pointSize: 20 ToolTip.text: checked ? "Pause Player" : "Play Sequence" onCheckedChanged: { m.playing = checked } Connections { target: m function onPlayingChanged() { playButton.checked = m.playing } } } Slider { id: frameSlider Layout.fillWidth: true value: m.frame stepSize: 1 snapMode: Slider.SnapAlways live: true from: frameRange.min to: frameRange.max onValueChanged: { m.frame = value } onPressedChanged: { if (!pressed) { updateSceneView() } } ToolTip { parent: frameSlider.handle visible: frameSlider.hovered text: m.frame } background: Rectangle { x: frameSlider.leftPadding y: frameSlider.topPadding + frameSlider.height / 2 - height / 2 width: frameSlider.availableWidth height: 4 radius: 2 color: Colors.grey Repeater { id: cacheView model: viewer ? viewer.cachedFrames : [] property real frameLength: sortedViewIds.length > 0 ? frameSlider.width / (frameRange.max - frameRange.min + 1) : 0 Rectangle { x: modelData.x * cacheView.frameLength y: 0 width: cacheView.frameLength * (modelData.y - modelData.x + 1) height: 4 radius: 2 color: Colors.blue } } } } RowLayout { TextInput { id: fpsTextInput Layout.preferredWidth: fpsMetrics.width selectByMouse: true text: !focus ? m.fps + " FPS" : m.fps color: palette.text onEditingFinished: { m.fps = parseInt(text) focus = false } } } MaterialToolButton { id: fetchButton text: MaterialIcons.subscriptions ToolTip.text: "Fetch" checkable: true checked: loading } MaterialToolButton { id: repeatButton checkable: true checked: false text: MaterialIcons.repeat ToolTip.text: "Repeat" onCheckedChanged: { m.repeat = checked } } MaterialToolButton { id: infoButton text: MaterialIcons.settings font.pointSize: 11 padding: 2 onClicked: infoMenu.open() checkable: true checked: infoMenu.visible Popup { id: infoMenu y: parent.height x: -width + parent.width contentItem: GridLayout { layoutDirection: Qt.LeftToRight columns: 2 Column { id: syncColumn Layout.alignment: Qt.AlignTop Text { text: "Synchronisation:" color: palette.text } CheckBox { id: syncFeaturePointsCheckBox text: "Sync Feature Points" checkable: true checked: m.syncFeaturesSelected onCheckedChanged: { m.syncFeaturesSelected = checked } ToolTip.text: "The Feature points will be updated at the same time as the Sequence Player." ToolTip.visible: hovered ToolTip.delay: 100 } CheckBox { id: sync3DCheckBox text: "Sync 3D Viewer" checkable: true checked: m.sync3DSelected onCheckedChanged: { m.sync3DSelected = checked } ToolTip.text: "The 3D Viewer will be updated at the same time as the Sequence Player." ToolTip.visible: hovered ToolTip.delay: 100 } } Column { id: cacheColumn Layout.alignment: Qt.AlignTop Text { text: "Cache:" color: palette.text } // max cache memory limit Row { height: sync3DCheckBox.height Text { anchors.verticalCenter: parent.verticalCenter text: "Max Cache Memory: " color: palette.text } TextField { id: maxCacheMemoryInput anchors.verticalCenter: parent.verticalCenter color: palette.text text: !focus ? settings_SequencePlayer.maxCacheMemory + " GB" : settings_SequencePlayer.maxCacheMemory onEditingFinished: { settings_SequencePlayer.maxCacheMemory = parseInt(text) focus = false } } } Text { height: sync3DCheckBox.height verticalAlignment: Text.AlignVCenter text: { if (viewer && viewer.ramInfo != undefined) return "Available Memory: " + viewer.ramInfo.x + " GB" return "Unknown Available Memory" } color: palette.text } Text { height: sync3DCheckBox.height verticalAlignment: Text.AlignVCenter text: { // number of cached frames is the difference between the first and last frame of all intervals in the cache let cachedFrames = viewer ? viewer.cachedFrames : [] let cachedFramesCount = 0 for (let i = 0; i < cachedFrames.length; i++) { cachedFramesCount += cachedFrames[i].y - cachedFrames[i].x + 1 } return "Cached Frames: " + (viewer ? cachedFramesCount : "0") + " / " + sortedViewIds.length } color: palette.text } // do beautiful progress bar ProgressBar { id: cacheProgressBar width: parent.width from: 0 to: viewer && viewer.ramInfo != undefined ? viewer.ramInfo.x : 0 value: viewer ? settings_SequencePlayer.maxCacheMemory : 0 ToolTip.text: { let ramMsg = "Max cache memory set: " + settings_SequencePlayer.maxCacheMemory + " GB" if (viewer && viewer.ramInfo != undefined) { return ramMsg + "\n" + "on available memory: " + viewer.ramInfo.x + " GB" } return ramMsg + ",\n" + "available memory unknown" } ToolTip.visible: hovered ToolTip.delay: 100 } ProgressBar { id: occupiedCacheProgressBar property string occupiedCache: viewer && viewer.ramInfo ? Format.GB2SizeStr(viewer.ramInfo.y) : 0 width: parent.width from: 0 to: settings_SequencePlayer.maxCacheMemory value: viewer && viewer.ramInfo != undefined ? viewer.ramInfo.y : 0 ToolTip.text: "Occupied cache: " + occupiedCache + "\n" + "On max cache memory set: " + settings_SequencePlayer.maxCacheMemory + " GB" ToolTip.visible: hovered ToolTip.delay: 100 } } } } } } TextMetrics { id: fpsMetrics font: fpsTextInput.font text: "100 FPS" } // Action to play/pause the sequence player Action { id: playPauseAction shortcut: "Space" onTriggered: { m.playing = !m.playing } } } ================================================ FILE: meshroom/ui/qml/Viewer/SfmGlobalStats.qml ================================================ import QtCharts import QtQuick import QtQuick.Controls import QtQuick.Layouts import AliceVision 1.0 as AliceVision import Charts 1.0 import Controls 1.0 import Utils 1.0 FloatingPane { id: root property var msfmData property var mTracks property color textColor: Colors.sysPalette.text visible: (_currentScene.sfm && _currentScene.sfm.isComputed) ? root.visible : false clip: true padding: 4 // To avoid interaction with components in background MouseArea { anchors.fill: parent acceptedButtons: Qt.LeftButton | Qt.RightButton | Qt.MiddleButton onPressed: {} onReleased: {} onWheel: {} } InteractiveChartView { id: residualsPerViewChart width: parent.width * 0.5 height: parent.height * 0.5 title: "Residuals Per View" legend.visible: false antialiasing: true ValueAxis { id: residualsPerViewValueAxisX labelFormat: "%i" titleText: "Ordered Views" min: 0 max: sfmDataStat.residualsPerViewMaxAxisX } ValueAxis { id: residualsPerViewValueAxisY titleText: "Reprojection Error (pix)" min: 0 max: sfmDataStat.residualsPerViewMaxAxisY tickAnchor: 0 tickInterval: 0.50 tickCount: sfmDataStat.residualsPerViewMaxAxisY * 2 } LineSeries { id: residualsMinPerViewLineSerie axisX: residualsPerViewValueAxisX axisY: residualsPerViewValueAxisY name: "Min" } LineSeries { id: residualsMaxPerViewLineSerie axisX: residualsPerViewValueAxisX axisY: residualsPerViewValueAxisY name: "Max" } LineSeries { id: residualsMeanPerViewLineSerie axisX: residualsPerViewValueAxisX axisY: residualsPerViewValueAxisY name: "Mean" } LineSeries { id: residualsMedianPerViewLineSerie axisX: residualsPerViewValueAxisX axisY: residualsPerViewValueAxisY name: "Median" } LineSeries { id: residualsFirstQuartilePerViewLineSerie axisX: residualsPerViewValueAxisX axisY: residualsPerViewValueAxisY name: "Q1" } LineSeries { id: residualsThirdQuartilePerViewLineSerie axisX: residualsPerViewValueAxisX axisY: residualsPerViewValueAxisY name: "Q3" } } Item { id: residualsPerViewBtnContainer Layout.fillWidth: true anchors.bottom: residualsPerViewChart.bottom anchors.bottomMargin: 35 anchors.left: residualsPerViewChart.left anchors.leftMargin: residualsPerViewChart.width * 0.25 RowLayout { ChartViewCheckBox { id: allObservations text: "ALL" color: textColor checkState: residualsPerViewLegend.buttonGroup.checkState onClicked: { var _checked = checked; for (var i = 0; i < residualsPerViewChart.count; ++i) { residualsPerViewChart.series(i).visible = _checked } } } ChartViewLegend { id: residualsPerViewLegend chartView: residualsPerViewChart } } } InteractiveChartView { id: observationsLengthsPerViewChart width: parent.width * 0.5 height: parent.height * 0.5 anchors.top: parent.top anchors.topMargin: (parent.height) * 0.5 title: "Observations Lengths Per View" legend.visible: false antialiasing: true ValueAxis { id: observationsLengthsPerViewValueAxisX labelFormat: "%i" titleText: "Ordered Views" min: 0 max: sfmDataStat.observationsLengthsPerViewMaxAxisX } ValueAxis { id: observationsLengthsPerViewValueAxisY titleText: "Observations Lengths" min: 0 max: sfmDataStat.observationsLengthsPerViewMaxAxisY tickAnchor: 0 tickInterval: 0.50 tickCount: sfmDataStat.observationsLengthsPerViewMaxAxisY * 2 } LineSeries { id: observationsLengthsMinPerViewLineSerie axisX: observationsLengthsPerViewValueAxisX axisY: observationsLengthsPerViewValueAxisY name: "Min" } LineSeries { id: observationsLengthsMaxPerViewLineSerie axisX: observationsLengthsPerViewValueAxisX axisY: observationsLengthsPerViewValueAxisY name: "Max" } LineSeries { id: observationsLengthsMeanPerViewLineSerie axisX: observationsLengthsPerViewValueAxisX axisY: observationsLengthsPerViewValueAxisY name: "Mean" } LineSeries { id: observationsLengthsMedianPerViewLineSerie axisX: observationsLengthsPerViewValueAxisX axisY: observationsLengthsPerViewValueAxisY name: "Median" } LineSeries { id: observationsLengthsFirstQuartilePerViewLineSerie axisX: observationsLengthsPerViewValueAxisX axisY: observationsLengthsPerViewValueAxisY name: "Q1" } LineSeries { id: observationsLengthsThirdQuartilePerViewLineSerie axisX: observationsLengthsPerViewValueAxisX axisY: observationsLengthsPerViewValueAxisY name: "Q3" } } Item { id: observationsLengthsPerViewBtnContainer Layout.fillWidth: true anchors.bottom: observationsLengthsPerViewChart.bottom anchors.bottomMargin: 35 anchors.left: observationsLengthsPerViewChart.left anchors.leftMargin: observationsLengthsPerViewChart.width * 0.25 RowLayout { ChartViewCheckBox { id: allModes text: "ALL" color: textColor checkState: observationsLengthsPerViewLegend.buttonGroup.checkState onClicked: { var _checked = checked; for (var i = 0; i < observationsLengthsPerViewChart.count; ++i) { observationsLengthsPerViewChart.series(i).visible = _checked } } } ChartViewLegend { id: observationsLengthsPerViewLegend chartView: observationsLengthsPerViewChart } } } InteractiveChartView { id: landmarksPerViewChart width: parent.width * 0.5 height: parent.height * 0.5 anchors.left: parent.left anchors.leftMargin: (parent.width) * 0.5 anchors.top: parent.top title: "Landmarks Per View" legend.visible: false antialiasing: true ValueAxis { id: landmarksPerViewValueAxisX titleText: "Ordered Views" min: 0.0 max: sfmDataStat.landmarksPerViewMaxAxisX } ValueAxis { id: landmarksPerViewValueAxisY labelFormat: "%i" titleText: "Number of Landmarks" min: 0 max: sfmDataStat.landmarksPerViewMaxAxisY } LineSeries { id: landmarksPerViewLineSerie axisX: landmarksPerViewValueAxisX axisY: landmarksPerViewValueAxisY name: "Landmarks" } LineSeries { id: tracksPerViewLineSerie axisX: landmarksPerViewValueAxisX axisY: landmarksPerViewValueAxisY name: "Tracks" } } Item { id: landmarksFeatTracksPerViewBtnContainer Layout.fillWidth: true anchors.bottom: landmarksPerViewChart.bottom anchors.bottomMargin: 35 anchors.left: landmarksPerViewChart.left anchors.leftMargin: landmarksPerViewChart.width * 0.25 RowLayout { ChartViewCheckBox { id: allFeatures text: "ALL" color: textColor checkState: landmarksFeatTracksPerViewLegend.buttonGroup.checkState onClicked: { var _checked = checked; for (var i = 0; i < landmarksPerViewChart.count; ++i) { landmarksPerViewChart.series(i).visible = _checked } } } ChartViewLegend { id: landmarksFeatTracksPerViewLegend chartView: landmarksPerViewChart } } } // Stats from the sfmData AliceVision.MSfMDataStats { id: sfmDataStat msfmData: root.msfmData mTracks: root.mTracks onAxisChanged: { fillLandmarksPerViewSerie(landmarksPerViewLineSerie) fillTracksPerViewSerie(tracksPerViewLineSerie) fillResidualsMinPerViewSerie(residualsMinPerViewLineSerie) fillResidualsMaxPerViewSerie(residualsMaxPerViewLineSerie) fillResidualsMeanPerViewSerie(residualsMeanPerViewLineSerie) fillResidualsMedianPerViewSerie(residualsMedianPerViewLineSerie) fillResidualsFirstQuartilePerViewSerie(residualsFirstQuartilePerViewLineSerie) fillResidualsThirdQuartilePerViewSerie(residualsThirdQuartilePerViewLineSerie) fillObservationsLengthsMinPerViewSerie(observationsLengthsMinPerViewLineSerie) fillObservationsLengthsMaxPerViewSerie(observationsLengthsMaxPerViewLineSerie) fillObservationsLengthsMeanPerViewSerie(observationsLengthsMeanPerViewLineSerie) fillObservationsLengthsMedianPerViewSerie(observationsLengthsMedianPerViewLineSerie) fillObservationsLengthsFirstQuartilePerViewSerie(observationsLengthsFirstQuartilePerViewLineSerie) fillObservationsLengthsThirdQuartilePerViewSerie(observationsLengthsThirdQuartilePerViewLineSerie) } } } ================================================ FILE: meshroom/ui/qml/Viewer/SfmStatsView.qml ================================================ import QtCharts import QtQuick import QtQuick.Controls import QtQuick.Layouts import AliceVision 1.0 as AliceVision import Charts 1.0 import Controls 1.0 import Utils 1.0 FloatingPane { id: root property var msfmData: null property int viewId property color textColor: Colors.sysPalette.text visible: (_currentScene.sfm && _currentScene.sfm.isComputed) ? root.visible : false clip: true padding: 4 // To avoid interaction with components in background MouseArea { anchors.fill: parent acceptedButtons: Qt.LeftButton | Qt.RightButton | Qt.MiddleButton onPressed: {} onReleased: {} onWheel: {} } InteractiveChartView { id: residualChart width: parent.width * 0.5 height: parent.height * 0.5 title: "Reprojection Errors" legend.visible: false antialiasing: true ValueAxis { id: residualValueAxisX titleText: "Reprojection Error" min: 0.0 max: viewStat.residualMaxAxisX } ValueAxis { id: residualValueAxisY labelFormat: "%i" titleText: "Number of Points" min: 0 max: viewStat.residualMaxAxisY } LineSeries { id: residualFullLineSerie axisX: residualValueAxisX axisY: residualValueAxisY name: "Average on All Cameras" } LineSeries { id: residualViewLineSerie axisX: residualValueAxisX axisY: residualValueAxisY name: "Current" } } Item { id: residualBtnContainer Layout.fillWidth: true anchors.bottom: residualChart.bottom anchors.bottomMargin: 35 anchors.left: residualChart.left anchors.leftMargin: residualChart.width * 0.15 RowLayout { ChartViewCheckBox { id: allResiduals text: "ALL" color: textColor checkState: residualLegend.buttonGroup.checkState onClicked: { var _checked = checked; for (var i = 0; i < residualChart.count; ++i) { residualChart.series(i).visible = _checked } } } ChartViewLegend { id: residualLegend chartView: residualChart } } } InteractiveChartView { id: observationsLengthsChart width: parent.width * 0.5 height: parent.height * 0.5 anchors.top: parent.top anchors.topMargin: (parent.height) * 0.5 legend.visible: false title: "Observations Lengths" ValueAxis { id: observationsLengthsvalueAxisX labelFormat: "%i" titleText: "Observations Length" min: 2 max: viewStat.observationsLengthsMaxAxisX tickAnchor: 2 tickInterval: 1 tickCount: 5 } ValueAxis { id: observationsLengthsvalueAxisY labelFormat: "%i" titleText: "Number of Points" min: 0 max: viewStat.observationsLengthsMaxAxisY } LineSeries { id: observationsLengthsFullLineSerie axisX: observationsLengthsvalueAxisX axisY: observationsLengthsvalueAxisY name: "All Cameras" } LineSeries { id: observationsLengthsViewLineSerie axisX: observationsLengthsvalueAxisX axisY: observationsLengthsvalueAxisY name: "Current" } } Item { id: observationsLengthsBtnContainer Layout.fillWidth: true anchors.bottom: observationsLengthsChart.bottom anchors.bottomMargin: 35 anchors.left: observationsLengthsChart.left anchors.leftMargin: observationsLengthsChart.width * 0.25 RowLayout { ChartViewCheckBox { id: allObservations text: "ALL" color: textColor checkState: observationsLengthsLegend.buttonGroup.checkState onClicked: { var _checked = checked; for (var i = 0; i < observationsLengthsChart.count; ++i) { observationsLengthsChart.series(i).visible = _checked } } } ChartViewLegend { id: observationsLengthsLegend chartView: observationsLengthsChart } } } InteractiveChartView { id: observationsScaleChart width: parent.width * 0.5 height: parent.height * 0.5 anchors.left: parent.left anchors.leftMargin: (parent.width) * 0.5 anchors.top: parent.top legend.visible: false title: "Observations Scale" ValueAxis { id: observationsScaleValueAxisX titleText: "Scale" min: 0 max: viewStat.observationsScaleMaxAxisX } ValueAxis { id: observationsScaleValueAxisY titleText: "Number of Points" min: 0 max: viewStat.observationsScaleMaxAxisY } LineSeries { id: observationsScaleFullLineSerie axisX: observationsScaleValueAxisX axisY: observationsScaleValueAxisY name: " Average on All Cameras" } LineSeries { id: observationsScaleViewLineSerie axisX: observationsScaleValueAxisX axisY: observationsScaleValueAxisY name: "Current" } } Item { id: observationsScaleBtnContainer Layout.fillWidth: true anchors.bottom: observationsScaleChart.bottom anchors.bottomMargin: 35 anchors.left: observationsScaleChart.left anchors.leftMargin: observationsScaleChart.width * 0.15 RowLayout { ChartViewCheckBox { id: allObservationsScales text: "ALL" color: textColor checkState: observationsScaleLegend.buttonGroup.checkState onClicked: { var _checked = checked; for (var i = 0; i < observationsScaleChart.count; ++i) { observationsScaleChart.series(i).visible = _checked } } } ChartViewLegend { id: observationsScaleLegend chartView: observationsScaleChart } } } // Stats from a view the sfmData AliceVision.MViewStats { id: viewStat msfmData: (root.visible && root.msfmData && root.msfmData.status === AliceVision.MSfMData.Ready) ? root.msfmData : null viewId: root.viewId onViewStatsChanged: { fillResidualFullSerie(residualFullLineSerie) fillResidualViewSerie(residualViewLineSerie) fillObservationsLengthsFullSerie(observationsLengthsFullLineSerie) fillObservationsLengthsViewSerie(observationsLengthsViewLineSerie) fillObservationsScaleFullSerie(observationsScaleFullLineSerie) fillObservationsScaleViewSerie(observationsScaleViewLineSerie) } } } ================================================ FILE: meshroom/ui/qml/Viewer/TestAliceVisionPlugin.qml ================================================ import QtQuick import AliceVision 1.0 /** * To evaluate if the QtAliceVision plugin is available. */ Item { id: root } ================================================ FILE: meshroom/ui/qml/Viewer/TextViewer.qml ================================================ import QtQuick import QtQuick.Controls import QtQuick.Layouts import Controls 1.0 import MaterialIcons 2.2 import Utils 1.0 /** * TextViewer displays the content of a text file (e.g. .txt, .json, .log, .csv). */ FocusScope { id: root clip: true property url source: "" Rectangle { anchors.fill: parent color: Qt.darker(palette.base, 1.1) ColumnLayout { anchors.fill: parent spacing: 0 // File path toolbar RowLayout { id: filePathBar Layout.fillWidth: true spacing: 4 visible: source.toString() !== "" TextField { id: filePathTextField Layout.fillWidth: true text: Filepath.urlToString(root.source) font.pointSize: 8 readOnly: true selectByMouse: true background: Item {} padding: 4 } MaterialToolButton { text: MaterialIcons.content_copy ToolTip.text: "Copy File Path to Clipboard" font.pointSize: 10 padding: 4 onClicked: { filePathTextField.selectAll() filePathTextField.copy() filePathTextField.deselect() } } } Rectangle { Layout.fillWidth: true height: 1 color: palette.mid visible: filePathBar.visible } // Text content area TextFileViewer { Layout.fillWidth: true Layout.fillHeight: true source: root.source } } } } ================================================ FILE: meshroom/ui/qml/Viewer/Viewer2D.qml ================================================ import QtQuick import QtQuick.Controls import QtQuick.Layouts import Controls 1.0 import MaterialIcons 2.2 import Utils 1.0 FocusScope { id: root clip: true property var displayedNode: null property var displayedAttr: (displayedNode && outputAttribute.name != "gallery") ? displayedNode.attributes.get(outputAttribute.name) : null property var displayedAttrValue: displayedAttr ? displayedAttr.value : "" property bool useExternal: false property url sourceExternal property url source property var viewIn3D property Component floatViewerComp: Qt.createComponent("FloatImage.qml") property Component panoramaViewerComp: Qt.createComponent("PanoramaViewer.qml") property var useFloatImageViewer: displayHDR.checked property alias useLensDistortionViewer: displayLensDistortionViewer.checked property alias usePanoramaViewer: displayPanoramaViewer.checked property var activeNodeFisheye: _currentScene ? _currentScene.activeNodes.get("PanoramaInit").node : null property bool cropFisheye : activeNodeFisheye ? activeNodeFisheye.attribute("useFisheye").value : false property bool enable8bitViewer: enable8bitViewerAction.checked property bool enableSequencePlayer: enableSequencePlayerAction.checked readonly property alias sync3DSelected: sequencePlayer.sync3DSelected property var sequence: [] property alias currentFrame: sequencePlayer.frameId property alias frameRange: sequencePlayer.frameRange property bool fittedOnce: false property int previousWidth: -1 property int previousHeight: -1 property int previousOrientationTag: 1 // State for double-click zoom toggle property real previousZoomScale: -1.0 property real previousZoomX: 0.0 property real previousZoomY: 0.0 QtObject { id: m property variant viewpointMetadata: { // Metadata from viewpoint attribute // Read from the scene object if (_currentScene) { let vp = getViewpoint(_currentScene.selectedViewId) if (vp) { return JSON.parse(vp.childAttribute("metadata").value) } } return {} } property variant imgMetadata: { // Metadata from FloatImage viewer // Directly read from the image file on disk if (floatImageViewerLoader.active && floatImageViewerLoader.item) { return floatImageViewerLoader.item.metadata } // Metadata from PhongImageViewer // Directly read from the image file on disk if (phongImageViewerLoader.active) { return phongImageViewerLoader.item.metadata } // Use viewpoint metadata for the special case of the 8-bit viewer if (qtImageViewerLoader.active) { return viewpointMetadata } return {} } } Loader { id: aliceVisionPluginLoader active: true source: "TestAliceVisionPlugin.qml" } readonly property bool aliceVisionPluginAvailable: aliceVisionPluginLoader.status === Component.Ready Component.onCompleted: { if (!aliceVisionPluginAvailable) { console.warn("Missing plugin qtAliceVision.") displayHDR.checked = false } } property string loadingModules: { if (!imgContainer.image) return "" var res = "" if (imgContainer.image.imageStatus === Image.Loading) { res += " Image" } if (mfeaturesLoader.status === Loader.Ready) { if (mfeaturesLoader.item && mfeaturesLoader.item.status === MFeatures.Loading) res += " Features" } if (mtracksLoader.status === Loader.Ready) { if (mtracksLoader.item && mtracksLoader.item.status === MTracks.Loading) res += " Tracks" } if (msfmDataLoader.status === Loader.Ready) { if (msfmDataLoader.item && msfmDataLoader.item.status === MSfMData.Loading) res += " SfMData" } return res } function clear() { source = "" } // Slots Keys.onPressed: function(event) { if (event.key === Qt.Key_F) { root.fit() event.accepted = true } else if (event.key === Qt.Key_1) { root.zoomPixelSize() event.accepted = true } } // Mouse area MouseArea { anchors.fill: parent property double factor: 1.2 acceptedButtons: Qt.LeftButton | Qt.RightButton | Qt.MiddleButton onPressed: function(mouse) { imgContainer.forceActiveFocus() if (mouse.button & Qt.MiddleButton || (mouse.button & Qt.LeftButton && mouse.modifiers & Qt.ShiftModifier)) drag.target = imgContainer // Start drag } onReleased: function(mouse) { drag.target = undefined // Stop drag if (mouse.button & Qt.RightButton) { var menu = contextMenu.createObject(root) menu.x = mouse.x menu.y = mouse.y menu.mousePos = Qt.point(mouse.x, mouse.y) menu.open() } } onDoubleClicked: function(mouse) { if (Math.abs(imgContainer.scale - 1.0) > 0.001) { // Not at 100%: save current state and zoom to 100% keeping cursor position root.previousZoomScale = imgContainer.scale root.previousZoomX = imgContainer.x root.previousZoomY = imgContainer.y zoomPixelSize(mouse.x, mouse.y) } else if (root.previousZoomScale > 0) { // Already at 100%: restore previous zoom state imgContainer.scale = root.previousZoomScale imgContainer.x = root.previousZoomX imgContainer.y = root.previousZoomY root.previousZoomScale = -1.0 } } onWheel: function(wheel) { var zoomFactor = wheel.angleDelta.y > 0 ? factor : 1 / factor if (Math.min(imgContainer.width, imgContainer.image.height) * imgContainer.scale * zoomFactor < 10) return var point = mapToItem(imgContainer, wheel.x, wheel.y) imgContainer.x += (1 - zoomFactor) * point.x * imgContainer.scale imgContainer.y += (1 - zoomFactor) * point.y * imgContainer.scale imgContainer.scale *= zoomFactor } } onEnable8bitViewerChanged: { if (!enable8bitViewer) { displayHDR.checked = true } } // Functions function fit() { // Make sure the image is ready for use if (!imgContainer.image) { return false } // For Exif orientation tags 5 to 8, a 90 degrees rotation is applied // therefore image dimensions must be inverted let dimensionsInverted = ["5", "6", "7", "8"].includes(imgContainer.orientationTag) let orientedWidth = dimensionsInverted ? imgContainer.image.height : imgContainer.image.width let orientedHeight = dimensionsInverted ? imgContainer.image.width : imgContainer.image.height // Fit oriented image imgContainer.scale = Math.min(imgLayout.width / orientedWidth, root.height / orientedHeight) imgContainer.x = Math.max((imgLayout.width - orientedWidth * imgContainer.scale) * 0.5, 0) imgContainer.y = Math.max((imgLayout.height - orientedHeight * imgContainer.scale) * 0.5, 0) // Correct position when image dimensions are inverted // so that container center corresponds to image center imgContainer.x += (orientedWidth - imgContainer.image.width) * 0.5 * imgContainer.scale imgContainer.y += (orientedHeight - imgContainer.image.height) * 0.5 * imgContainer.scale return true } function zoomPixelSize(mouseX, mouseY) { var newScale = 1.0 if (mouseX !== undefined && mouseY !== undefined) { var point = mapToItem(imgContainer, mouseX, mouseY) imgContainer.x += (imgContainer.scale - newScale) * point.x imgContainer.y += (imgContainer.scale - newScale) * point.y } else { imgContainer.x = Math.max((imgLayout.width - imgContainer.width * newScale) * 0.5, 0) imgContainer.y = Math.max((imgLayout.height - imgContainer.height * newScale) * 0.5, 0) } imgContainer.scale = newScale } function tryLoadNode(node) { useExternal = false // Safety check if (!node) { return false } // Node must be computed or at least running if (node.isComputableType && !node.isPartiallyFinished()) { return false } // Node must have at least one output attribute with the image semantic if (!node.hasImageOutput && !node.hasSequenceOutput) { return false } displayedNode = node return true } function loadExternal(path) { useExternal = true sourceExternal = path displayedNode = null } function getViewpoint(viewId) { // Get viewpoint from cameraInit with matching id // This requires to loop over all viewpoints for (var i = 0; i < _currentScene.viewpoints.count; i++) { var vp = _currentScene.viewpoints.at(i) if (vp.childAttribute("viewId").value == viewId) { return vp } } return undefined } function getImageFile() { if (useExternal) { // Entry point for getting the image file from an external URL return sourceExternal } if (_currentScene && (!displayedNode || outputAttribute.name == "gallery")) { // Entry point for getting the image file from the gallery let vp = getViewpoint(_currentScene.pickedViewId) let path = vp ? vp.childAttribute("path").value : "" _currentScene.currentViewPath = path return Filepath.stringToUrl(path) } if (_currentScene && displayedNode && displayedNode.hasSequenceOutput && displayedAttr && (displayedAttr.desc.semantic === "imageList" || displayedAttr.desc.semantic === "sequence")) { // Entry point for getting the image file from a sequence defined by an output attribute var path = sequence[currentFrame - frameRange.min] _currentScene.currentViewPath = path return Filepath.stringToUrl(path) } if (_currentScene) { // Entry point for getting the image file from an output attribute and associated to the current viewpoint let vp = getViewpoint(_currentScene.pickedViewId) let path = displayedAttr ? displayedAttr.value : "" let resolved = vp ? Filepath.resolve(path, vp) : path _currentScene.currentViewPath = resolved return Filepath.stringToUrl(resolved) } return undefined } function buildOrderedSequence(pathTemplate) { // Resolve the path template on the sequence of viewpoints // ordered by path let outputFiles = [] if (displayedNode && displayedNode.hasSequenceOutput && displayedAttr) { // Reset current frame to 0 if it is imageList but not sequence if (displayedAttr.desc.semantic === "imageList") { let includesSeqMissingFiles = false // list only the existing files let [_, filesSeqs] = Filepath.resolveSequence(pathTemplate, includesSeqMissingFiles) // Concat in one array all sequences in resolved outputFiles = [].concat.apply([], filesSeqs) let newFrameRange = [0, outputFiles.length - 1] if(frameRange.min != newFrameRange[0] || frameRange.max != newFrameRange[1]) { frameRange.min = newFrameRange[0] frameRange.max = newFrameRange[1] // Change the current frame, only if the frame range is different currentFrame = frameRange.min } enableSequencePlayerAction.checked = true } if (displayedAttr.desc.semantic === "sequence") { let includesSeqMissingFiles = true let [frameRanges, filesSeqs] = Filepath.resolveSequence(pathTemplate, includesSeqMissingFiles) let newFrameRange = [0, 0] if (filesSeqs.length > 0) { // If there is one or several sequences, take the first one outputFiles = filesSeqs[0] newFrameRange = frameRanges[0] if(frameRange.min != newFrameRange[0] || frameRange.max != newFrameRange[1]) { frameRange.min = newFrameRange[0] frameRange.max = newFrameRange[1] // Change the current frame, only if the frame range is different currentFrame = frameRange.min } } enableSequencePlayerAction.checked = true } } else { let objs = [] for (let i = 0; i < _currentScene.viewpoints.count; i++) { objs.push(_currentScene.viewpoints.at(i)) } objs.sort((a, b) => { return a.childAttribute("path").value < b.childAttribute("path").value ? -1 : 1; }) for (let i = 0; i < objs.length; i++) { outputFiles.push(Filepath.resolve(pathTemplate, objs[i])) } frameRange.min = 0 frameRange.max = outputFiles.length - 1 } return outputFiles } function getSequence() { // Entry point for getting the current image sequence if (useExternal) { return [] } if (_currentScene && (!displayedNode || outputAttribute.name == "gallery")) { return buildOrderedSequence("") } if (_currentScene) { return buildOrderedSequence(displayedAttrValue) } return [] } function setAttributeName(attrName) { outputAttribute.setName(attrName) } onDisplayedNodeChanged: { if (!displayedNode) { root.source = "" } // Update output attribute names var names = [] if (displayedNode) { // Store attr name for output attributes that represent images for (var i = 0; i < displayedNode.attributes.count; i++) { var attr = displayedNode.attributes.at(i) if (attr.isOutput && (attr.desc.semantic === "image" || attr.desc.semantic === "sequence" || attr.desc.semantic === "imageList") && attr.enabled) { names.push(attr.name) } } } if (!displayedNode || displayedNode.isComputableType) names.push("gallery") outputAttribute.names = names outputAttribute.lastOutputName = names.find(n => n !== "gallery") || "" } onDisplayedAttrValueChanged: { if (displayedNode && !displayedNode.hasSequenceOutput) { root.source = getImageFile() root.sequence = [] } else { root.source = "" root.sequence = getSequence() if (currentFrame > frameRange.max) currentFrame = frameRange.min } } onDisplayedAttrChanged: { _currentScene.displayedAttr2D = displayedAttr } Connections { target: _currentScene function onSelectedViewIdChanged() { root.source = getImageFile() if (useExternal) useExternal = false } } Connections { target: displayedNode function onOutputAttrChanged() { tryLoadNode(displayedNode) } } // context menu property Component contextMenu: Menu { property point mousePos: Qt.point(0, 0) MenuItem { text: "Fit" onTriggered: fit() } MenuItem { text: "Zoom 100%" onTriggered: { zoomPixelSize(mousePos.x, mousePos.y) } } } ColumnLayout { anchors.fill: parent HdrImageToolbar { id: hdrImageToolbar anchors.margins: 0 visible: displayImageToolBarAction.checked && displayImageToolBarAction.enabled Layout.fillWidth: true onVisibleChanged: { resetDefaultValues() } colorPickerVisible: { return !displayPanoramaViewer.checked && !displayPhongLighting.checked } colorRGBA: { if (!floatImageViewerLoader.item || floatImageViewerLoader.item.imageStatus !== Image.Ready) { return null } /// Get the pixel color value at mouse position (when mouse hover the image) if (mousePosition && floatImageViewerLoader.item.containsMouse === true) { return floatImageViewerLoader.item.pixelValueAt( mousePosition.x, mousePosition.y ) } if ( !Number.isInteger(userDefinedXPixel) || !Number.isInteger(userDefinedYPixel) ) { return null } // Get the pixel color value from text field value (let the possibility to user to set the x,y from ui) return floatImageViewerLoader.item.pixelValueAt( parseInt(userDefinedXPixel) , parseInt(userDefinedYPixel) ) } mousePosition: (floatImageViewerLoader.item && floatImageViewerLoader.item.containsMouse ? { x: Math.floor(floatImageViewerLoader.item.mouseX), y: Math.floor(floatImageViewerLoader.item.mouseY) } : null) } LensDistortionToolbar { id: lensDistortionImageToolbar anchors.margins: 0 visible: displayLensDistortionToolBarAction.checked && displayLensDistortionToolBarAction.enabled Layout.fillWidth: true } PanoramaToolbar { id: panoramaViewerToolbar anchors.margins: 0 visible: displayPanoramaToolBarAction.checked && displayPanoramaToolBarAction.enabled Layout.fillWidth: true } // Image Item { id: imgLayout Layout.fillWidth: true Layout.fillHeight: true clip: true Image { id: alphaBackground anchors.fill: parent visible: displayAlphaBackground.checked fillMode: Image.Tile horizontalAlignment: Image.AlignLeft verticalAlignment: Image.AlignTop source: "../../img/checkerboard_light.png" scale: 4 smooth: false } Item { id: imgContainer transformOrigin: Item.TopLeft property var orientationTag: m.imgMetadata ? m.imgMetadata["Orientation"] : 0 // qtAliceVision Image Viewer ExifOrientedViewer { id: floatImageViewerLoader active: root.aliceVisionPluginAvailable && (root.useFloatImageViewer || root.useLensDistortionViewer) && !panoramaViewerLoader.active && !phongImageViewerLoader.active visible: (floatImageViewerLoader.status === Loader.Ready) && active anchors.centerIn: parent orientationTag: imgContainer.orientationTag xOrigin: imgContainer.width / 2 yOrigin: imgContainer.height / 2 property real resizeRatio: imgContainer.scale function sizeChanged() { /* Image size is not updated through a single signal with the floatImage viewer, unlike * the simple QML image viewer: instead of updating straight away the width and height to x and * y, the emitted signals look like: * - width = -1, height = -1 * - width = x, height = -1 * - width = x, height = y * We want to do the auto-fit on the first display of an image from the group, and then keep its * scale when displaying another image from the group, so we need to know if an image in the * group has already been auto-fitted. If we change the group of images (when another project is * opened, for example, and the images have a different size), then another auto-fit needs to be * performed */ var sizeValid = (width > 0) && (height > 0) var layoutValid = (root.width > 50) && (root.height > 50) var sizeChanged = (root.previousWidth != width) || (root.previousHeight != height) if ((!root.fittedOnce && imgContainer.image && sizeValid && layoutValid) || (root.fittedOnce && sizeChanged && sizeValid && layoutValid)) { var ret = fit() if (!ret) return root.fittedOnce = true root.previousWidth = width root.previousHeight = height if (orientationTag != undefined) root.previousOrientationTag = orientationTag } } onWidthChanged : { floatImageViewerLoader.sizeChanged(); } Connections { target: root function onWidthChanged() { floatImageViewerLoader.sizeChanged() } function onHeightChanged() { floatImageViewerLoader.sizeChanged() } } onOrientationTagChanged: { /* For images of the same width and height but with different orientations, the auto-fit * will not be triggered by the "widthChanged()" signal, so it needs to be triggered upon * either a change in the image's size or in its orientation. */ if (orientationTag != undefined && root.previousOrientationTag != orientationTag) { var ret = fit() if (!ret) return root.previousWidth = width root.previousHeight = height root.previousOrientationTag = orientationTag } } onActiveChanged: { if (active) { // Instantiate and initialize a FloatImage component dynamically using Loader.setSource // Note: It does not work to use previously created component, so we re-create it with setSource. floatImageViewerLoader.setSource("FloatImage.qml", { "source": Qt.binding(function() { return getImageFile() }), "gamma": Qt.binding(function() { return hdrImageToolbar.gammaValue }), "gain": Qt.binding(function() { return hdrImageToolbar.gainValue }), "channelModeString": Qt.binding(function() { return hdrImageToolbar.channelModeValue }), "isPrincipalPointsDisplayed": Qt.binding(function() { return lensDistortionImageToolbar.displayPrincipalPoint }), "surface.displayGrid": Qt.binding(function() { return lensDistortionImageToolbar.visible && lensDistortionImageToolbar.displayGrid }), "surface.gridOpacity": Qt.binding(function() { return lensDistortionImageToolbar.opacityValue }), "surface.gridColor": Qt.binding(function() { return lensDistortionImageToolbar.color }), "surface.subdivisions": Qt.binding(function() { return root.useFloatImageViewer ? 1 : lensDistortionImageToolbar.subdivisionsValue }), "viewerTypeString": Qt.binding(function() { return displayLensDistortionViewer.checked ? "distortion" : "hdr" }), "surface.msfmData": Qt.binding(function() { return (msfmDataLoader.status === Loader.Ready && msfmDataLoader.item != null && msfmDataLoader.item.status === 2) ? msfmDataLoader.item : null }), "canBeHovered": false, "idView": Qt.binding(function() { return ((root.displayedNode && !root.displayedNode.hasSequenceOutput && _currentScene) ? _currentScene.selectedViewId : -1) }), "cropFisheye": false, "sequence": Qt.binding(function() { return ((root.enableSequencePlayer && (_currentScene || (root.displayedNode && root.displayedNode.hasSequenceOutput))) ? getSequence() : []) }), "resizeRatio": Qt.binding(function() { return floatImageViewerLoader.resizeRatio }), "useSequence": Qt.binding(function() { return (root.enableSequencePlayer && !useExternal && (_currentScene || (root.displayedNode && root.displayedNode.hasSequenceOutput && (displayedAttr.desc.semantic === "imageList" || displayedAttr.desc.semantic === "sequence")))) }), "fetchingSequence": Qt.binding(function() { return sequencePlayer.loading }), "memoryLimit": Qt.binding(function() { return sequencePlayer.settings_SequencePlayer.maxCacheMemory }), }) } else { // Forcing the unload (instead of using Component.onCompleted to load it once and for all) is necessary since Qt 5.14 floatImageViewerLoader.setSource("", {}) fittedOnce = false } } } // qtAliceVision Panorama Viewer Loader { id: panoramaViewerLoader active: root.aliceVisionPluginAvailable && root.usePanoramaViewer && _currentScene.activeNodes.get('sfm').node visible: (panoramaViewerLoader.status === Loader.Ready) && active anchors.centerIn: parent onActiveChanged: { if (active) { setSource("PanoramaViewer.qml", { "subdivisionsPano": Qt.binding(function() { return panoramaViewerToolbar.subdivisionsValue }), "cropFisheyePano": Qt.binding(function() { return root.cropFisheye }), "downscale": Qt.binding(function() { return panoramaViewerToolbar.downscaleValue }), "isEditable": Qt.binding(function() { return panoramaViewerToolbar.enableEdit }), "isHighlightable": Qt.binding(function() { return panoramaViewerToolbar.enableHover }), "displayGridPano": Qt.binding(function() { return panoramaViewerToolbar.displayGrid }), "mouseMultiplier": Qt.binding(function() { return panoramaViewerToolbar.mouseSpeed }), "msfmData": Qt.binding(function() { return (msfmDataLoader && msfmDataLoader.item && msfmDataLoader.status === Loader.Ready && msfmDataLoader.item.status === 2) ? msfmDataLoader.item : null }), }) } else { // Forcing the unload (instead of using Component.onCompleted to load it once and for all) is necessary since Qt 5.14 setSource("", {}) displayPanoramaViewer.checked = false } } } // qtAliceVision Phong Image Viewer ExifOrientedViewer { id: phongImageViewerLoader active: root.aliceVisionPluginAvailable && displayPhongLighting.enabled && displayPhongLighting.checked visible: (phongImageViewerLoader.status === Loader.Ready) && active anchors.centerIn: parent orientationTag: imgContainer.orientationTag xOrigin: imgContainer.width / 2 yOrigin: imgContainer.height / 2 property var selectedNode: _currentScene ? _currentScene.selectedNode : null property var vp: _currentScene ? getViewpoint(_currentScene.selectedViewId) : null property url sourcePath: getAlbedoFile() property url normalPath: getNormalFile() property bool fittedOnce: false property int previousWidth: 0 property int previousHeight: 0 property int previousOrientationTag: 1 function getAlbedoFile() { // get the image file from an external URL if (useExternal) { var externalFile = Filepath.urlToString(sourceExternal) if(externalFile.includes("_normals")) return Filepath.stringToUrl(externalFile.replace("_normals", "_albedo")) return sourceExternal } // get the image file from selected node albedo attribute if(vp && selectedNode && selectedNode.hasAttribute("albedo")) return Filepath.stringToUrl(Filepath.resolve(selectedNode.attribute("albedo").value, vp)) // no valid image file, return empty url return "" } function getNormalFile() { // get the image file from an external URL if (useExternal) { var externalFile = Filepath.urlToString(sourceExternal) if(externalFile.includes("_normals")) return sourceExternal if(externalFile.includes("_albedo")) return Filepath.stringToUrl(externalFile.replace("_albedo", "_normals")) return "" // invalid external file } // get the image file from selected node normals attribute if(vp && selectedNode && selectedNode.hasAttribute("normals")) return Filepath.stringToUrl(Filepath.resolve(selectedNode.attribute("normals").value, vp)) // no valid image file, return empty url return "" } onWidthChanged: { /* We want to do the auto-fit on the first display of an image from the group, and then keep its * scale when displaying another image from the group, so we need to know if an image in the * group has already been auto-fitted. If we change the group of images (when another project is * opened, for example, and the images have a different size), then another auto-fit needs to be * performed */ if ((!fittedOnce && imgContainer.image && imgContainer.image.width > 0) || (fittedOnce && ((width > 1 && previousWidth != width) || (height > 1 && previousHeight != height)))) { var ret = fit() if (!ret) return fittedOnce = true previousWidth = width previousHeight = height if (orientationTag != undefined) previousOrientationTag = orientationTag } } onOrientationTagChanged: { /* For images of the same width and height but with different orientations, the auto-fit * will not be triggered by the "widthChanged()" signal, so it needs to be triggered upon * either a change in the image's size or in its orientation. */ if (orientationTag != undefined && previousOrientationTag != orientationTag) { var ret = fit() if (!ret) return fittedOnce = true previousWidth = width previousHeight = height previousOrientationTag = orientationTag } } onActiveChanged: { if (active) { /* Instantiate and initialize a PhongImageViewer component dynamically using Loader.setSource * Note: It does not work to use previously created component, so we re-create it with setSource. */ setSource("PhongImageViewer.qml", { 'sourcePath': Qt.binding(function() { return sourcePath }), 'normalPath': Qt.binding(function() { return normalPath }), 'gamma': Qt.binding(function() { return hdrImageToolbar.gammaValue }), 'gain': Qt.binding(function() { return hdrImageToolbar.gainValue }), 'channelModeString': Qt.binding(function() { return hdrImageToolbar.channelModeValue }), 'baseColor': Qt.binding(function() { return phongImageViewerToolbarLoader.item !== null ? phongImageViewerToolbarLoader.item.baseColorValue : "#ffffff" }), 'textureOpacity': Qt.binding(function() { return phongImageViewerToolbarLoader.item !== null ? phongImageViewerToolbarLoader.item.textureOpacityValue : 0.0}), 'ka': Qt.binding(function() { return phongImageViewerToolbarLoader.item !== null ? phongImageViewerToolbarLoader.item.kaValue : 0.0 }), 'kd': Qt.binding(function() { return phongImageViewerToolbarLoader.item !== null ? phongImageViewerToolbarLoader.item.kdValue : 0.0 }), 'ks': Qt.binding(function() { return phongImageViewerToolbarLoader.item !== null ? phongImageViewerToolbarLoader.item.ksValue : 0.0 }), 'shininess': Qt.binding(function() { return phongImageViewerToolbarLoader.item !== null ? phongImageViewerToolbarLoader.item.shininessValue : 0.0 }), 'lightYaw': Qt.binding(function() { return directionalLightPaneLoader.item !== null ? -directionalLightPaneLoader.item.lightYawValue : 0.0 }), // left handed coordinate system 'lightPitch': Qt.binding(function() { return directionalLightPaneLoader.item !== null ? directionalLightPaneLoader.item.lightPitchValue : 0.0 }), }) } else { // Forcing the unload (instead of using Component.onCompleted to load it once and for all) is necessary since Qt 5.14 setSource("", {}) fittedOnce = false } } } // Simple QML Image Viewer (using Qt or qtAliceVisionImageIO to load images) ExifOrientedViewer { id: qtImageViewerLoader active: !floatImageViewerLoader.active && !panoramaViewerLoader.active && !phongImageViewerLoader.active anchors.centerIn: parent orientationTag: imgContainer.orientationTag xOrigin: imgContainer.width / 2 yOrigin: imgContainer.height / 2 sourceComponent: Image { id: qtImageViewer asynchronous: true smooth: false fillMode: Image.PreserveAspectFit onWidthChanged: if (status==Image.Ready) fit() source: getImageFile() onStatusChanged: { // Update cache source when image is loaded imageStatus = status if (status === Image.Ready) qtImageViewerCache.source = source } property var imageStatus: Image.Ready // Image cache of the last loaded image // Only visible when the main one is loading, to maintain a displayed image for smoother transitions Image { id: qtImageViewerCache anchors.fill: parent asynchronous: true smooth: parent.smooth fillMode: parent.fillMode visible: qtImageViewer.status === Image.Loading } } } property var image: { if (floatImageViewerLoader.active) floatImageViewerLoader.item else if (panoramaViewerLoader.active) panoramaViewerLoader.item else if (phongImageViewerLoader.active) phongImageViewerLoader.item else qtImageViewerLoader.item } width: image ? (image.width > 0 ? image.width : 1) : 1 height: image ? (image.height > 0 ? image.height : 1) : 1 scale: 1.0 // FeatureViewer: display view extracted feature points // Note: requires QtAliceVision plugin - use a Loader to evaluate plugin availability at runtime ExifOrientedViewer { id: featuresViewerLoader active: displayFeatures.checked && !useExternal property var activeNode: _currentScene ? _currentScene.activeNodes.get("featureProvider").node : null width: imgContainer.width height: imgContainer.height anchors.centerIn: parent orientationTag: imgContainer.orientationTag xOrigin: imgContainer.width / 2 yOrigin: imgContainer.height / 2 onActiveChanged: { if (active) { // Instantiate and initialize a FeaturesViewer component dynamically using Loader.setSource setSource("FeaturesViewer.qml", { "model": Qt.binding(function() { return activeNode ? activeNode.attribute("describerTypes").value : "" }), "currentViewId": Qt.binding(function() { return _currentScene.selectedViewId }), "features": Qt.binding(function() { return mfeaturesLoader.status === Loader.Ready ? mfeaturesLoader.item : null }), "tracks": Qt.binding(function() { return mtracksLoader.status === Loader.Ready ? mtracksLoader.item : null }), "sfmData": Qt.binding(function() { return msfmDataLoader.status === Loader.Ready ? msfmDataLoader.item : null }), "syncFeaturesSelected": Qt.binding(function() { return sequencePlayer.syncFeaturesSelected }), }) } else { // Forcing the unload (instead of using Component.onCompleted to load it once and for all) is necessary since Qt 5.14 setSource("", {}) } } } // ShapeViewer: display shapes and texts from current node shape attributes and json files // Note: use a Loader ExifOrientedViewer { anchors.centerIn: parent width: imgContainer.width height: imgContainer.height xOrigin: imgContainer.width * 0.5 yOrigin: imgContainer.height * 0.5 orientationTag: imgContainer.orientationTag active: _currentScene ? (_currentScene.selectedNode ? _currentScene.selectedNode.hasDisplayableShape : false) : false onActiveChanged: { if (active) { setSource("../Shapes/Viewer/ShapeViewer.qml", { "containerWidth": Qt.binding(function() { return imgContainer.width }), "containerHeight": Qt.binding(function() { return imgContainer.height }), "containerScale": Qt.binding(function() { return imgContainer.scale }) }) } else { // forcing the unload (instead of using Component.onCompleted to load it once and for all) is necessary since Qt 5.14 setSource("", {}) } } } // FisheyeCircleViewer: display fisheye circle // Note: use a Loader to evaluate if a PanoramaInit node exist and displayFisheyeCircle checked at runtime ExifOrientedViewer { anchors.centerIn: parent orientationTag: imgContainer.orientationTag xOrigin: imgContainer.width / 2 yOrigin: imgContainer.height / 2 property var activeNode: _currentScene ? _currentScene.activeNodes.get("PanoramaInit").node : null active: displayFisheyeCircleLoader.checked && activeNode sourceComponent: CircleGizmo { width: imgContainer.width height: imgContainer.height property bool useAuto: activeNode.attribute("estimateFisheyeCircle").value readOnly: useAuto visible: (!useAuto) || activeNode.isComputed property real userFisheyeRadius: activeNode.attribute("fisheyeRadius").value property variant fisheyeAutoParams: _currentScene.getAutoFisheyeCircle(activeNode) circleX: useAuto ? fisheyeAutoParams.x : activeNode.attribute("fisheyeCenterOffset.fisheyeCenterOffset_x").value circleY: useAuto ? fisheyeAutoParams.y : activeNode.attribute("fisheyeCenterOffset.fisheyeCenterOffset_y").value circleRadius: useAuto ? fisheyeAutoParams.z : ((imgContainer.image ? Math.min(imgContainer.image.width, imgContainer.image.height) : 1.0) * 0.5 * (userFisheyeRadius * 0.01)) circleBorder.width: Math.max(1, (3.0 / imgContainer.scale)) onMoved: function(xoffset, yoffset) { if (!useAuto) { _currentScene.setAttribute( activeNode.attribute("fisheyeCenterOffset"), JSON.stringify([xoffset, yoffset]) ) } } onIncrementRadius: function(radiusOffset) { if (!useAuto) { _currentScene.setAttribute(activeNode.attribute("fisheyeRadius"), activeNode.attribute("fisheyeRadius").value + radiusOffset) } } } } // LightingCalibration: display circle ExifOrientedViewer { property var activeNode: _currentScene.activeNodes.get("SphereDetection").node anchors.centerIn: parent orientationTag: imgContainer.orientationTag xOrigin: imgContainer.width / 2 yOrigin: imgContainer.height / 2 active: displayLightingCircleLoader.checked && activeNode sourceComponent: CircleGizmo { property var jsonFolder: activeNode.attribute("output").value property var json: null property var currentViewId: _currentScene.selectedViewId property var nodeCircleX: activeNode.attribute("sphereCenter.x").value property var nodeCircleY: activeNode.attribute("sphereCenter.y").value property var nodeCircleRadius: activeNode.attribute("sphereRadius").value width: imgContainer.width height: imgContainer.height readOnly: activeNode.attribute("autoDetect").value circleX: nodeCircleX circleY: nodeCircleY circleRadius: nodeCircleRadius circleBorder.width: Math.max(1, (3.0 / imgContainer.scale)) onJsonFolderChanged: { json = null if (activeNode.attribute("autoDetect").value) { // Auto detection enabled var jsonPath = activeNode.attribute("output").value Request.get(Filepath.stringToUrl(jsonPath), function(xhr) { if (xhr.readyState === XMLHttpRequest.DONE) { try { json = JSON.parse(xhr.responseText) } catch(exc) { console.warn("Failed to parse SphereDetection JSON file: " + jsonPath) } } updateGizmo() }) } } onCurrentViewIdChanged: { updateGizmo() } onNodeCircleXChanged : { updateGizmo() } onNodeCircleYChanged : { updateGizmo() } onNodeCircleRadiusChanged : { updateGizmo() } function updateGizmo() { if (activeNode.attribute("autoDetect").value) { // Update gizmo from auto detection JSON file if (json) { // JSON file found var data = json[currentViewId] if (data && data[0]) { // Current view id found circleX = data[0].x circleY= data[0].y circleRadius = data[0].r return } } // No auto detection data circleX = -1 circleY= -1 circleRadius = 0 } else { // Update gizmo from node manual parameters circleX = nodeCircleX circleY = nodeCircleY circleRadius = nodeCircleRadius } } onMoved: { _currentScene.setAttribute(activeNode.attribute("sphereCenter"), JSON.stringify([xoffset, yoffset])) } onIncrementRadius: { _currentScene.setAttribute(activeNode.attribute("sphereRadius"), activeNode.attribute("sphereRadius").value + radiusOffset) } } } // ColorCheckerViewer: display color checker detection results // Note: use a Loader to evaluate if a ColorCheckerDetection node exist and displayColorChecker checked at runtime ExifOrientedViewer { id: colorCheckerViewerLoader anchors.centerIn: parent orientationTag: imgContainer.orientationTag xOrigin: imgContainer.width / 2 yOrigin: imgContainer.height / 2 property var activeNode: _currentScene ? _currentScene.activeNodes.get("ColorCheckerDetection").node : null active: (displayColorCheckerViewerLoader.checked && activeNode) sourceComponent: ColorCheckerViewer { width: imgContainer.width height: imgContainer.height visible: activeNode.isComputed && json !== undefined && imgContainer.image.imageStatus === Image.Ready source: Filepath.stringToUrl(activeNode.attribute("outputData").value) viewpoint: _currentScene.selectedViewpoint zoom: imgContainer.scale updatePane: function() { colorCheckerPane.colors = getColors(); } } } } ColumnLayout { anchors.fill: parent spacing: 0 FloatingPane { id: imagePathToolbar Layout.fillWidth: true Layout.fillHeight: false Layout.preferredHeight: childrenRect.height visible: displayImagePathAction.checked RowLayout { width: parent.width height: childrenRect.height // Selectable filepath to source image TextField { padding: 0 background: Item {} horizontalAlignment: TextInput.AlignLeft Layout.fillWidth: true height: contentHeight font.pointSize: 8 readOnly: true selectByMouse: true text: (phongImageViewerLoader.active) ? Filepath.urlToString(phongImageViewerLoader.sourcePath) : Filepath.urlToString(getImageFile()) } // Write which node is being displayed Label { id: displayedNodeName text: root.displayedNode ? root.displayedNode.label : "" font.pointSize: 8 horizontalAlignment: TextInput.AlignLeft Layout.fillWidth: false Layout.preferredWidth: contentWidth height: contentHeight } // Button to clear currently displayed file MaterialToolButton { id: clearViewerButton text: MaterialIcons.close ToolTip.text: root.useExternal ? "Close external file" : "Clear node" enabled: root.displayedNode || root.useExternal visible: root.displayedNode || root.useExternal onClicked: { if (root.displayedNode) root.displayedNode = null if (root.useExternal) root.useExternal = false } } } } FloatingPane { Layout.fillWidth: true Layout.fillHeight: false Layout.preferredHeight: childrenRect.height visible: floatImageViewerLoader.item !== null && floatImageViewerLoader.item.imageStatus === Image.Error Layout.alignment: Qt.AlignHCenter RowLayout { anchors.fill: parent Label { font.pointSize: 8 text: { if (floatImageViewerLoader.item !== null) { switch (floatImageViewerLoader.item.status) { case 2: // AliceVision.FloatImageViewer.EStatus.OUTDATED_LOADING return "Outdated Loading" case 3: // AliceVision.FloatImageViewer.EStatus.MISSING_FILE return "Missing File" case 4: // AliceVision.FloatImageViewer.EStatus.LOADING_ERROR return "Error" default: return "" } } return "" } horizontalAlignment: Text.AlignHCenter verticalAlignment: Text.AlignVCenter Layout.fillWidth: true Layout.alignment: Qt.AlignHCenter } } } FloatingPane { Layout.fillWidth: true Layout.fillHeight: false Layout.preferredHeight: childrenRect.height visible: phongImageViewerLoader.item !== null && phongImageViewerLoader.item.imageStatus === Image.Error && phongImageViewerLoader.sourcePath != "" Layout.alignment: Qt.AlignHCenter RowLayout { anchors.fill: parent Label { font.pointSize: 8 text: { if (phongImageViewerLoader.item !== null) { switch (phongImageViewerLoader.item.status) { case 2: // AliceVision.PhongImageViewer.EStatus.MISSING_FILE return "Invalid / Missing File(s)" case 4: // AliceVision.PhongImageViewer.EStatus.LOADING_ERROR return "Error" default: return "" } } return "" } horizontalAlignment: Text.AlignHCenter verticalAlignment: Text.AlignVCenter Layout.fillWidth: true Layout.alignment: Qt.AlignHCenter } } } Item { id: imgPlaceholder Layout.fillWidth: true Layout.fillHeight: true // Image Metadata overlay Pane ImageMetadataView { width: 350 anchors { top: parent.top right: parent.right bottom: parent.bottom } visible: metadataCB.checked // Only load metadata model if visible metadata: { if (visible) { if (root.useExternal || outputAttribute.name != "gallery") { return m.imgMetadata } else { return m.viewpointMetadata } } return {} } } ColorCheckerPane { id: colorCheckerPane width: 250 height: 170 anchors { top: parent.top right: parent.right } visible: displayColorCheckerViewerLoader.checked && colorCheckerPane.colors !== null } Loader { id: mfeaturesLoader property bool isUsed: displayFeatures.checked property var activeNode: { if (!root.aliceVisionPluginAvailable) { return null } return _currentScene ? _currentScene.activeNodes.get("featureProvider").node : null } property bool isComputed: activeNode && activeNode.isComputed active: isUsed && isComputed onActiveChanged: { if (active) { // Instantiate and initialize a MFeatures component dynamically using Loader.setSource // so it can fail safely if the C++ plugin is not available setSource("MFeatures.qml", { "describerTypes": Qt.binding(function() { return activeNode ? activeNode.attribute("describerTypes").value : {} }), "featureFolders": Qt.binding(function() { let result = [] if (activeNode) { if (activeNode.nodeType == "FeatureExtraction" && isComputed) { result.push(activeNode.attribute("output").value) } else if (activeNode.nodeType == "RomaReducer" && isComputed) { result.push(activeNode.attribute("featuresFolder").value) } else if (activeNode.hasAttribute("featuresFolders")) { for (let i = 0; i < activeNode.attribute("featuresFolders").value.count; i++) { let attr = activeNode.attribute("featuresFolders").value.at(i) result.push(attr.value) } } } return result }), "viewIds": Qt.binding(function() { if (_currentScene) { let result = []; for (let i = 0; i < _currentScene.viewpoints.count; i++) { let vp = _currentScene.viewpoints.at(i) result.push(vp.childAttribute("viewId").value) } return result } return {} }), }) } else { // Forcing the unload (instead of using Component.onCompleted to load it once and for all) is necessary since Qt 5.14 setSource("", {}) } } } Loader { id: msfmDataLoader property bool isUsed: displayFeatures.checked || displaySfmStatsView.checked || displaySfmDataGlobalStats.checked || displayPanoramaViewer.checked || displayLensDistortionViewer.checked property var activeNode: { if (!root.aliceVisionPluginAvailable) { return null } var nodeType = "sfm" if (displayLensDistortionViewer.checked) { nodeType = "sfmData" } var sfmNode = _currentScene ? _currentScene.activeNodes.get(nodeType).node : null if (sfmNode === null) { return null } if (displayPanoramaViewer.checked) { sfmNode = _currentScene.activeNodes.get('SfMTransform').node var previousNode = sfmNode.attribute("input").inputRootLink.node return previousNode } return sfmNode } property bool isComputed: activeNode && activeNode.isComputed property string filepath: { var sfmValue = "" if (isComputed && activeNode.hasAttribute("output")) { sfmValue = activeNode.attribute("output").value } return Filepath.stringToUrl(sfmValue) } active: isUsed && isComputed onActiveChanged: { if (active) { // Instantiate and initialize a SfmStatsView component dynamically using Loader.setSource // so it can fail safely if the c++ plugin is not available setSource("MSfMData.qml", { "sfmDataPath": Qt.binding(function() { return filepath }), }) } else { // Force the unload (instead of using Component.onCompleted to load it once and for all) is necessary since Qt 5.14 setSource("", {}) } } } Loader { id: mtracksLoader property bool isUsed: displayFeatures.checked || displaySfmStatsView.checked || displaySfmDataGlobalStats.checked || displayPanoramaViewer.checked property var activeNode: { if (!root.aliceVisionPluginAvailable) { return null } if (_currentScene) { //Try first to use tracks if (_currentScene.activeNodes.get("trackProvider").node) { return _currentScene.activeNodes.get("trackProvider").node } return _currentScene.activeNodes.get("matchProvider").node } return null } property bool isComputed: activeNode && activeNode.isComputed active: isUsed && isComputed onActiveChanged: { if (active) { // instantiate and initialize a mTracks component // dynamically using Loader.setSource so it can fail safely // if the c++ plugin is not available setSource("MTracks.qml", { "matchingFolders": Qt.binding(function() { let result = [] if (activeNode) { if (activeNode.nodeType == "FeatureMatching" && isComputed) { result.push(activeNode.attribute("output").value) } else if (activeNode.hasAttribute("matchesFolders")) { for (let i = 0; i < activeNode.attribute("matchesFolders").value.count; i++) { let attr = activeNode.attribute("matchesFolders").value.at(i) result.push(attr.value) } } } return result }), "tracksFile": Qt.binding(function() { let result = "" if (activeNode) { if (activeNode.nodeType == "TracksBuilding" && isComputed) { result = activeNode.attribute("output").value } else if (activeNode.hasAttribute("tracksFilename")) { result = activeNode.attribute("tracksFilename").value } } return result }) }) } else { // Forcing the unload (instead of using Component.onCompleted to load it once and for all) is necessary since Qt 5.14 setSource("", {}) } } } Loader { id: sfmStatsView anchors.fill: parent active: msfmDataLoader.status === Loader.Ready && displaySfmStatsView.checked onActiveChanged: { // Load and unload the component explicitly // (necessary since Qt 5.14, Component.onCompleted cannot be used anymore to load the data once and for all) if (active) { setSource("SfmStatsView.qml", { "msfmData": Qt.binding(function() { return msfmDataLoader.item }), "viewId": Qt.binding(function() { return _currentScene.selectedViewId }), }) } else { setSource("", {}) } } } Loader { id: sfmGlobalStats anchors.fill: parent active: msfmDataLoader.status === Loader.Ready && displaySfmDataGlobalStats.checked onActiveChanged: { // Load and unload the component explicitly // (necessary since Qt 5.14, Component.onCompleted cannot be used anymore to load the data once and for all) if (active) { setSource("SfmGlobalStats.qml", { "msfmData": Qt.binding(function() { return msfmDataLoader.item }), "mTracks": Qt.binding(function() { return mtracksLoader.item }), }) } else { setSource("", {}) } } } Loader { id: featuresOverlay anchors { bottom: parent.bottom left: parent.left margins: 2 } active: root.aliceVisionPluginAvailable && displayFeatures.checked && featuresViewerLoader.status === Loader.Ready sourceComponent: FeaturesInfoOverlay { pluginStatus: featuresViewerLoader.status featuresViewer: featuresViewerLoader.item mfeatures: mfeaturesLoader.item mtracks: mtracksLoader.item msfmdata: msfmDataLoader.item featuresNodeName: (mfeaturesLoader.activeNode) ? mfeaturesLoader.activeNode.label : "None" tracksNodeName: (mtracksLoader.activeNode) ? mtracksLoader.activeNode.label : "None" sfmdataNodeName: (msfmDataLoader.activeNode) ? msfmDataLoader.activeNode.label : "None" } } Loader { id: ldrHdrCalibrationGraph anchors.fill: parent property var activeNode: _currentScene ? _currentScene.activeNodes.get("LdrToHdrCalibration").node : null property var isEnabled: displayLdrHdrCalibrationGraph.checked && activeNode && activeNode.isComputed active: isEnabled property var path: activeNode && activeNode.hasAttribute("response") ? activeNode.attribute("response").value : "" property var vp: _currentScene ? getViewpoint(_currentScene.selectedViewId) : null sourceComponent: CameraResponseGraph { responsePath: Filepath.resolve(path, vp) } } Loader { id: phongImageViewerToolbarLoader active: phongImageViewerLoader.status === Loader.Ready anchors { bottom: parent.bottom left: parent.left margins: 2 } sourceComponent: PhongImageViewerToolbar { } } Loader { id: directionalLightPaneLoader active: phongImageViewerToolbarLoader.status === Loader.Ready anchors { bottom: parent.bottom right: parent.right margins: 2 } sourceComponent: DirectionalLightPane { visible: phongImageViewerToolbarLoader.item !== null && phongImageViewerToolbarLoader.item.displayLightController } } } FloatingPane { id: bottomToolbar padding: 4 Layout.fillWidth: true Layout.preferredHeight: childrenRect.height RowLayout { anchors.fill: parent // Zoom label MLabel { text: ((imgContainer.image && (imgContainer.image.imageStatus === Image.Ready)) ? imgContainer.scale.toFixed(2) : "1.00") + "x" ToolTip.text: "Zoom" MouseArea { anchors.fill: parent acceptedButtons: Qt.LeftButton | Qt.RightButton onClicked: function(mouse) { if (mouse.button & Qt.LeftButton) { fit() } else if (mouse.button & Qt.RightButton) { var menu = contextMenu.createObject(root) var point = mapToItem(root, mouse.x, mouse.y) menu.x = point.x menu.y = point.y menu.mousePos = Qt.point(point.x, point.y) menu.open() } } } } MaterialToolButton { id: displayAlphaBackground ToolTip.text: "Alpha Background" text: MaterialIcons.texture font.pointSize: 11 Layout.minimumWidth: 0 checkable: true } MaterialToolButton { id: displayHDR ToolTip.text: "High-Dynamic-Range Image Viewer" text: MaterialIcons.hdr_on // Larger font but smaller padding, so it is visually similar font.pointSize: 20 padding: 0 Layout.minimumWidth: 0 checkable: true checked: root.aliceVisionPluginAvailable enabled: root.aliceVisionPluginAvailable visible: root.enable8bitViewer onCheckedChanged : { if (displayLensDistortionViewer.checked && checked) { displayLensDistortionViewer.checked = false } root.useFloatImageViewer = !root.useFloatImageViewer } } MaterialToolButton { id: displayLensDistortionViewer property int numberChanges: 0 property bool previousChecked: false property var activeNode: root.aliceVisionPluginAvailable && _currentScene ? _currentScene.activeNodes.get("sfmData").node : null property bool isComputed: { if (!activeNode) return false if (activeNode.isComputed) return true if (!activeNode.hasAttribute("input")) return false var inputAttr = activeNode.attribute("input") var inputAttrLink = inputAttr.inputRootLink if (!inputAttrLink) return false return inputAttrLink.node.isComputed } ToolTip.text: "Lens Distortion Viewer" + (isComputed ? (": " + activeNode.label) : "") text: MaterialIcons.panorama_horizontal font.pointSize: 16 padding: 0 Layout.minimumWidth: 0 checkable: true checked: false enabled: activeNode && isComputed onCheckedChanged : { if ((displayHDR.checked || displayPanoramaViewer.checked) && checked) { displayHDR.checked = false displayPanoramaViewer.checked = false } else if (!checked) { displayHDR.checked = true } } onActiveNodeChanged: { numberChanges += 1 } onEnabledChanged: { if (!enabled) { previousChecked = checked checked = false numberChanges = 0 } if (enabled && (numberChanges == 1) && previousChecked) { checked = true } } } MaterialToolButton { id: displayPanoramaViewer property var activeNode: root.aliceVisionPluginAvailable && _currentScene ? _currentScene.activeNodes.get("SfMTransform").node : null property bool isComputed: { if (!activeNode) return false if (activeNode.attribute("method").value !== "manual") return false var inputAttr = activeNode.attribute("input") if (!inputAttr) return false var inputAttrLink = inputAttr.inputRootLink if (!inputAttrLink) return false return inputAttrLink.node.isComputed } ToolTip.text: activeNode ? "Panorama Viewer " + activeNode.label : "Panorama Viewer" text: MaterialIcons.panorama_photosphere font.pointSize: 16 padding: 0 Layout.minimumWidth: 0 checkable: true checked: false enabled: activeNode && isComputed onCheckedChanged : { if (displayLensDistortionViewer.checked && checked) { displayLensDistortionViewer.checked = false } if (displayFisheyeCircleLoader.checked && checked) { displayFisheyeCircleLoader.checked = false } } onEnabledChanged : { if (!enabled) { checked = false } } } MaterialToolButton { id: displayFeatures ToolTip.text: "Display Features" text: MaterialIcons.scatter_plot font.pointSize: 11 Layout.minimumWidth: 0 checkable: true && !useExternal checked: false enabled: root.aliceVisionPluginAvailable && !displayPanoramaViewer.checked && !useExternal onEnabledChanged : { if (useExternal) return if (enabled == false) checked = false } } MaterialToolButton { id: displayFisheyeCircleLoader property var activeNode: _currentScene ? _currentScene.activeNodes.get("PanoramaInit").node : null ToolTip.text: "Display Fisheye Circle: " + (activeNode ? activeNode.label : "No Node") text: MaterialIcons.vignette font.pointSize: 11 Layout.minimumWidth: 0 checkable: true checked: false enabled: activeNode && activeNode.attribute("useFisheye").value && !displayPanoramaViewer.checked visible: activeNode } MaterialToolButton { id: displayLightingCircleLoader property var activeNode: _currentScene.activeNodes.get("SphereDetection").node ToolTip.text: "Display Lighting Circle: " + (activeNode ? activeNode.label : "No Node") text: MaterialIcons.location_searching font.pointSize: 11 Layout.minimumWidth: 0 checkable: true checked: false enabled: activeNode visible: activeNode } MaterialToolButton { id: displayPhongLighting property var activeNode: _currentScene.activeNodes.get('PhotometricStereo').node ToolTip.text: "Display Phong Lighting: " + (activeNode ? activeNode.label : "No Node") text: MaterialIcons.light_mode font.pointSize: 11 Layout.minimumWidth: 0 checkable: true checked: false enabled: activeNode visible: activeNode } MaterialToolButton { id: displayColorCheckerViewerLoader property var activeNode: _currentScene ? _currentScene.activeNodes.get("ColorCheckerDetection").node : null ToolTip.text: "Display Color Checker: " + (activeNode ? activeNode.label : "No Node") text: MaterialIcons.view_comfy font.pointSize: 11 Layout.minimumWidth: 0 checkable: true enabled: activeNode && activeNode.isComputed && _currentScene.selectedViewId !== -1 checked: false visible: activeNode onEnabledChanged: { if (enabled == false) checked = false } onCheckedChanged: { if (checked == true) { displaySfmDataGlobalStats.checked = false displaySfmStatsView.checked = false metadataCB.checked = false } } } MaterialToolButton { id: displayLdrHdrCalibrationGraph property var activeNode: _currentScene ? _currentScene.activeNodes.get("LdrToHdrCalibration").node : null property bool isComputed: activeNode && activeNode.isComputed ToolTip.text: "Display Camera Response Function: " + (activeNode ? activeNode.label : "No Node") text: MaterialIcons.timeline font.pointSize: 11 Layout.minimumWidth: 0 checkable: true checked: false enabled: activeNode && activeNode.isComputed visible: activeNode onIsComputedChanged: { if (!isComputed) checked = false } } Label { id: resolutionLabel Layout.fillWidth: true text: (imgContainer.image && imgContainer.image.sourceSize.width > 0) ? (imgContainer.image.sourceSize.width + "x" + imgContainer.image.sourceSize.height) : "" elide: Text.ElideRight horizontalAlignment: Text.AlignHCenter } ComboBox { id: outputAttribute clip: true Layout.minimumWidth: 0 flat: true property var names: ["gallery"] property string name: names[currentIndex] property string lastOutputName: "" model: names.map(n => (n === "gallery") ? "Image Gallery" : displayedNode.attributes.get(n).label) enabled: count > 1 FontMetrics { id: fontMetrics } Layout.preferredWidth: model.reduce((acc, label) => Math.max(acc, fontMetrics.boundingRect(label).width), 0) + 3.0 * Qt.application.font.pixelSize onNameChanged: { if (name !== "gallery") lastOutputName = name root.source = getImageFile() root.sequence = getSequence() } function setName(attrName) { const attrIndex = outputAttribute.names.indexOf(attrName) if (attrIndex > -1) { outputAttribute.currentIndex = attrIndex } } } MaterialToolButton { id: displayImageOutputIn3D enabled: root.aliceVisionPluginAvailable && _currentScene && displayedNode && Filepath.basename(root.source).includes("depth") ToolTip.text: "View Depth Map in 3D" text: MaterialIcons.input font.pointSize: 11 Layout.minimumWidth: 0 onClicked: { root.viewIn3D( root.source, displayedNode.name + ":" + outputAttribute.name + " " + String(_currentScene.selectedViewId) ) } } MaterialToolButton { id: displaySfmStatsView property var activeNode: root.aliceVisionPluginAvailable && _currentScene ? _currentScene.activeNodes.get("sfm").node : null property bool isComputed: activeNode && activeNode.isComputed font.family: MaterialIcons.fontFamily text: MaterialIcons.assessment ToolTip.text: "StructureFromMotion Statistics" + (isComputed ? (": " + activeNode.label) : "") ToolTip.visible: hovered font.pointSize: 14 padding: 2 smooth: false flat: true checkable: enabled enabled: activeNode && activeNode.isComputed && _currentScene.selectedViewId >= 0 onCheckedChanged: { if (checked == true) { displaySfmDataGlobalStats.checked = false metadataCB.checked = false displayColorCheckerViewerLoader.checked = false } } } MaterialToolButton { id: displaySfmDataGlobalStats property var activeNode: root.aliceVisionPluginAvailable && _currentScene ? _currentScene.activeNodes.get("sfm").node : null property bool isComputed: activeNode && activeNode.isComputed font.family: MaterialIcons.fontFamily text: MaterialIcons.language ToolTip.text: "StructureFromMotion Global Statistics" + (isComputed ? (": " + activeNode.label) : "") ToolTip.visible: hovered font.pointSize: 14 padding: 2 smooth: false flat: true checkable: enabled enabled: activeNode && activeNode.isComputed onCheckedChanged: { if (checked == true) { displaySfmStatsView.checked = false metadataCB.checked = false displayColorCheckerViewerLoader.checked = false } } } MaterialToolButton { id: metadataCB font.family: MaterialIcons.fontFamily text: MaterialIcons.info_outline ToolTip.text: "Image Metadata" ToolTip.visible: hovered font.pointSize: 14 padding: 2 smooth: false flat: true checkable: enabled onCheckedChanged: { if (checked == true) { displaySfmDataGlobalStats.checked = false displaySfmStatsView.checked = false displayColorCheckerViewerLoader.checked = false } } } } } SequencePlayer { id: sequencePlayer anchors.margins: 0 Layout.fillWidth: true sortedViewIds: { return (root.enableSequencePlayer && (root.displayedNode && root.displayedNode.hasSequenceOutput)) ? root.sequence : (_currentScene && _currentScene.viewpoints.count > 0) ? buildOrderedSequence("") : [] } viewer: floatImageViewerLoader.status === Loader.Ready ? floatImageViewerLoader.item : null visible: root.enableSequencePlayer enabled: root.enableSequencePlayer isOutputSequence: root.displayedNode && root.displayedNode.hasSequenceOutput } } } } // Busy indicator BusyIndicator { anchors.centerIn: parent // Running property binding seems broken, only dynamic binding assignment works Component.onCompleted: { running = Qt.binding(function() { return (root.usePanoramaViewer === true && imgContainer.image && imgContainer.image.allImagesLoaded === false) || (imgContainer.image && imgContainer.image.imageStatus === Image.Loading) }) } // Disable the visibility when unused to avoid stealing the mouseEvent to the image color picker visible: running onVisibleChanged: { if (panoramaViewerLoader.active) fit() } } // Actions for RGBA filters Action { id: rFilterAction shortcut: "R" onTriggered: { hdrImageToolbar.toggleChannel("r", "rgba") } } Action { id: gFilterAction shortcut: "G" onTriggered: { hdrImageToolbar.toggleChannel("g", "rgba") } } Action { id: bFilterAction shortcut: "B" onTriggered: { hdrImageToolbar.toggleChannel("b", "rgba") } } Action { id: aFilterAction shortcut: "A" onTriggered: { hdrImageToolbar.toggleChannel("a", "rgba") } } // Actions for Metadata overlay Action { id: metadataAction shortcut: "I" onTriggered: { metadataCB.checked = !metadataCB.checked } } // Actions switch Source Action { id: switchSourceAction shortcut: "S" onTriggered: { if (outputAttribute.name === "gallery") { outputAttribute.setName(outputAttribute.lastOutputName) } else { outputAttribute.setName("gallery") } } } } ================================================ FILE: meshroom/ui/qml/Viewer/qmldir ================================================ module Viewer Viewer2D 1.0 Viewer2D.qml ImageMetadataView 1.0 ImageMetadataView.qml TextViewer 1.0 TextViewer.qml ================================================ FILE: meshroom/ui/qml/Viewer3D/BoundingBox.qml ================================================ import Qt3D.Core 2.6 import Qt3D.Render 2.6 import Qt3D.Input 2.6 import Qt3D.Extras 2.15 import QtQuick Entity { id: root property Transform transform: Transform {} components: [transform] Entity { components: [cube, greyMaterial] CuboidMesh { id: cube property real edge : 1.995 // Almost 2: important to have all the cube's vertices with a unit of 1 xExtent: edge yExtent: edge zExtent: edge } PhongAlphaMaterial { id: greyMaterial property color base: "#fff" ambient: base alpha: 0.15 // Pretty convincing combination blendFunctionArg: BlendEquation.Add sourceRgbArg: BlendEquationArguments.SourceAlpha sourceAlphaArg: BlendEquationArguments.OneMinusSourceAlpha destinationRgbArg: BlendEquationArguments.DestinationColor destinationAlphaArg: BlendEquationArguments.OneMinusSourceAlpha } } Entity { components: [edges, orangeMaterial] PhongMaterial { id: orangeMaterial property color base: "#f49b2b" ambient: base } GeometryRenderer { id: edges primitiveType: GeometryRenderer.Lines geometry: Geometry { Attribute { id: boundingBoxPosition attributeType: Attribute.VertexAttribute vertexBaseType: Attribute.Float vertexSize: 3 count: 24 name: defaultPositionAttributeName buffer: Buffer { data: new Float32Array([ 1.0, 1.0, 1.0, 1.0, -1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, -1.0, 1.0, 1.0, 1.0, -1.0, 1.0, 1.0, -1.0, -1.0, -1.0, -1.0, 1.0, -1.0, -1.0, -1.0, -1.0, 1.0, -1.0, -1.0, -1.0, -1.0, -1.0, -1.0, -1.0, 1.0, 1.0, -1.0, 1.0, 1.0, -1.0, -1.0, 1.0, 1.0, -1.0, 1.0, -1.0, -1.0, -1.0, 1.0, 1.0, -1.0, 1.0, -1.0, 1.0, -1.0, 1.0, -1.0, -1.0, 1.0, -1.0, 1.0, 1.0, -1.0, -1.0, 1.0, -1.0, 1.0, -1.0, 1.0, 1.0, -1.0 ]) } } boundingVolumePositionAttribute: boundingBoxPosition } } } } ================================================ FILE: meshroom/ui/qml/Viewer3D/DefaultCameraController.qml ================================================ import QtQuick import Qt3D.Core 2.6 import Qt3D.Render 2.6 import Qt3D.Input 2.6 import Qt3D.Logic 2.6 import QtQml import Meshroom.Helpers 1.0 Entity { id: root property Camera camera property real translateSpeed: 75.0 property real tiltSpeed: 500.0 property real panSpeed: 500.0 property alias focus: keyboardHandler.focus readonly property bool pickingActive: actionControl.active && keyboardHandler._pressed property alias rotationSpeed: trackball.rotationSpeed property alias windowSize: trackball.windowSize property alias trackballSize: trackball.trackballSize property bool loseMouseFocus: false // Must be changed by other entities when they want to take mouse focus property bool moving: false property bool panning: false property bool zooming: false readonly property alias pressed: mouseHandler._pressed signal mousePressed(var mouse) signal mouseReleased(var mouse, var moved) signal mouseClicked(var mouse) signal mouseWheeled(var wheel) signal mouseDoubleClicked(var mouse) KeyboardDevice { id: keyboardSourceDevice } MouseDevice { id: mouseSourceDevice } TrackballController { id: trackball camera: root.camera } MouseHandler { id: mouseHandler property bool _pressed property point lastPosition property point currentPosition property bool hasMoved sourceDevice: loseMouseFocus ? null : mouseSourceDevice onPressed: function(mouse) { _pressed = true currentPosition.x = lastPosition.x = mouse.x currentPosition.y = lastPosition.y = mouse.y hasMoved = false mousePressed(mouse) } onReleased: function(mouse) { _pressed = false mouseReleased(mouse, hasMoved) } onClicked: function(mouse) { mouseClicked(mouse) } onPositionChanged: function(mouse) { currentPosition.x = mouse.x currentPosition.y = mouse.y const dt = 0.02 var d root.moving = mouse.buttons & Qt.LeftButton root.panning = (mouse.buttons & Qt.MiddleButton) var panningAlt = actionShift.active && (mouse.buttons & Qt.LeftButton) root.zooming = actionAlt.active && (mouse.buttons & Qt.RightButton) if (panning || panningAlt) { // Translate d = (root.camera.viewCenter.minus(root.camera.position)).length() * 0.03 var tx = axisMX.value * root.translateSpeed * d var ty = axisMY.value * root.translateSpeed * d mouseHandler.hasMoved = true root.camera.translate(Qt.vector3d(-tx, -ty, 0).times(dt)) return } if (moving) { // Trackball rotation trackball.rotate(mouseHandler.lastPosition, mouseHandler.currentPosition, dt) mouseHandler.lastPosition = mouseHandler.currentPosition mouseHandler.hasMoved = true return } if (zooming) { // Zoom with alt + RMD mouseHandler.hasMoved = true d = root.camera.viewCenter.minus(root.camera.position).length() // Distance between camera position and center position var zoomPower = 0.2 var tz = axisMX.value * root.translateSpeed * zoomPower // Translation to apply depending on user action (mouse move), bigger absolute value means we will zoom/dezoom more var tzThreshold = 0.001 // We forbid too big zoom, as it means the distance between camera and center would be too low and we will have no translation after (due to float representation) if (tz >= 0.9 * d) return // We forbid too small zoom as it means we are getting very close to center position and next zoom may lead to similar problem as previous cases (no translation), problem occurs only if tz > 0 (when we zoom) if (tz > 0 && tz <= tzThreshold) return root.camera.translate(Qt.vector3d(0, 0, tz), Camera.DontTranslateViewCenter) return } } onDoubleClicked: function(mouse) { mouseDoubleClicked(mouse) } onWheel: function(wheel) { var d = root.camera.viewCenter.minus(root.camera.position).length() // Distance between camera position and center position var zoomPower = 0.2 var angleStep = 120 var tz = (wheel.angleDelta.y / angleStep) * d * zoomPower // Translation to apply depending on user action (mouse wheel), bigger absolute value means we will zoom/dezoom more var tzThreshold = 0.001 // We forbid too big zoom, as it means the distance between camera and center would be too low and we will have no translation after (due to float representation) if (tz >= 0.9 * d) { return } // We forbid too small zoom as it means we are getting very close to center position and next zoom may lead to similar problem as previous cases (no translation), problem occurs only if tz > 0 (when we zoom) if (tz > 0 && tz <= tzThreshold) { return } root.camera.translate(Qt.vector3d(0, 0, tz), Camera.DontTranslateViewCenter) } } KeyboardHandler { id: keyboardHandler sourceDevice: keyboardSourceDevice property bool _pressed // When focus is lost while pressing a key, the corresponding action stays active, even when it is released. // Handle this issue manually by keeping an additional _pressed state // which is cleared when focus changes (used for 'pickingActive' property). onFocusChanged: function(focus) { if (!focus) _pressed = false } onPressed: _pressed = true onReleased: _pressed = false } LogicalDevice { id: cameraControlDevice actions: [ Action { id: actionLMB inputs: [ ActionInput { sourceDevice: mouseSourceDevice buttons: [MouseEvent.LeftButton] } ] }, Action { id: actionRMB inputs: [ ActionInput { sourceDevice: mouseSourceDevice buttons: [MouseEvent.RightButton] } ] }, Action { id: actionMMB inputs: [ ActionInput { sourceDevice: mouseSourceDevice buttons: [MouseEvent.MiddleButton] } ] }, Action { id: actionShift inputs: [ ActionInput { sourceDevice: keyboardSourceDevice buttons: [Qt.Key_Shift] } ] }, Action { id: actionControl inputs: [ ActionInput { sourceDevice: keyboardSourceDevice buttons: [Qt.Key_Control] } ] }, Action { id: actionAlt inputs: [ ActionInput { sourceDevice: keyboardSourceDevice buttons: [Qt.Key_Alt] } ] } ] axes: [ Axis { id: axisMX inputs: [ AnalogAxisInput { sourceDevice: mouseSourceDevice axis: MouseDevice.X } ] }, Axis { id: axisMY inputs: [ AnalogAxisInput { sourceDevice: mouseSourceDevice axis: MouseDevice.Y } ] } ] } } ================================================ FILE: meshroom/ui/qml/Viewer3D/DepthMapLoader.qml ================================================ import DepthMapEntity 2.1 /** * Support for Depth Map files (EXR) in Qt3d. * Create this component dynamically to test for DepthMapEntity plugin availability. */ DepthMapEntity { id: root pointSize: Viewer3DSettings.pointSize * (Viewer3DSettings.fixedPointSize ? 1.0 : 0.001) // Map render modes to custom visualization modes displayMode: Viewer3DSettings.renderMode == 1 ? DepthMapEntity.Points : DepthMapEntity.Triangles displayColor: Viewer3DSettings.renderMode == 2 } ================================================ FILE: meshroom/ui/qml/Viewer3D/EntityWithGizmo.qml ================================================ import Qt3D.Core 2.6 import Qt3D.Render 2.6 import Qt3D.Input 2.6 import Qt3D.Extras 2.15 import Qt3D.Logic 2.6 import QtQuick /** * Wrapper for TransformGizmo. * Must be instantiated to control an other entity. * The goal is to instantiate the other entity inside this wrapper to gather the object and the gizmo. * objectTranform is the component the other entity should use as a Transform. */ Entity { id: root property DefaultCameraController sceneCameraController property Layer frontLayerComponent property var window property alias uniformScale: transformGizmo.uniformScale // By default, if not set, the value is: false property TransformGizmo transformGizmo: TransformGizmo { id: transformGizmo camera: root.camera windowSize: root.windowSize frontLayerComponent: root.frontLayerComponent window: root.window onPickedChanged: function(pressed) { sceneCameraController.loseMouseFocus = pressed // Notify the camera if the transform takes/releases the focus } } readonly property Camera camera : sceneCameraController.camera readonly property var windowSize: sceneCameraController.windowSize readonly property alias objectTransform : transformGizmo.objectTransform // The Transform the object should use } ================================================ FILE: meshroom/ui/qml/Viewer3D/EnvironmentMapEntity.qml ================================================ import QtQuick import Qt3D.Core 2.6 import Qt3D.Render 2.6 import Qt3D.Extras 2.15 /** * EnvironmentMap maps an equirectangular image on a Sphere. * The 'position' property can be used to virually attach it to a camera * and get the impression of an environment at an infinite distance. */ Entity { id: root /// Source of the equirectangular image property url source /// Radius of the sphere property alias radius: sphereMesh.radius /// Number of slices of the sphere property alias slices: sphereMesh.slices /// Number of rings of the sphere property alias rings: sphereMesh.rings /// Position of the sphere property alias position: transform.translation /// Texture loading status property alias status: textureLoader.status components: [ SphereMesh { id: sphereMesh radius: 1000 slices: 50 rings: 50 }, Transform { id: transform translation: root.position }, DiffuseMapMaterial { ambient: "#FFF" shininess: 0 specular: "#000" diffuse: TextureLoader { id: textureLoader magnificationFilter: Texture.Linear mirrored: true source: root.source } } ] } ================================================ FILE: meshroom/ui/qml/Viewer3D/Grid3D.qml ================================================ import QtQuick import Qt3D.Core 2.6 import Qt3D.Render 2.6 import Qt3D.Extras 2.15 // Grid Entity { id: gridEntity components: [ GeometryRenderer { primitiveType: GeometryRenderer.Lines geometry: Geometry { Attribute { id: gridPosition attributeType: Attribute.VertexAttribute vertexBaseType: Attribute.Float vertexSize: 3 count: 0 name: defaultPositionAttributeName buffer: Buffer { data: { function buildGrid(first, last, offset, attribute) { var vertexCount = (((last - first) / offset) + 1) * 4 var f32 = new Float32Array(vertexCount * 3) for (var id = 0, i = first; i <= last; i += offset, id++) { f32[12 * id] = i f32[12 * id + 1] = 0.0 f32[12 * id + 2] = first f32[12 * id + 3] = i f32[12 * id + 4] = 0.0 f32[12 * id + 5] = last f32[12 * id + 6] = first f32[12 * id + 7] = 0.0 f32[12 * id + 8] = i f32[12 * id + 9] = last f32[12 * id + 10] = 0.0 f32[12 * id + 11] = i } attribute.count = vertexCount return f32 } return buildGrid(-12, 12, 1, gridPosition) } } } boundingVolumePositionAttribute: gridPosition } }, PhongMaterial { ambient: "#FFF" diffuse: "#222" specular: diffuse shininess: 0 } ] } ================================================ FILE: meshroom/ui/qml/Viewer3D/ImageOverlay.qml ================================================ import QtQuick import QtQuick.Layouts /** * ImageOverlay enables to display a Viewpoint image on top of a 3D View. * It takes the principal point correction into account and handle image ratio to * correctly fit or crop according to original image ratio and parent Item ratio. */ Item { id: root /// The URL of the image to display property alias source: image.source /// Source image ratio property real imageRatio: 1.0 /// Principal Point correction as UV coordinates offset property alias uvCenterOffset: shader.uvCenterOffset /// Whether to display the frame around the image property bool showFrame /// Opacity of the image property alias imageOpacity: shader.opacity implicitWidth: 300 implicitHeight: 300 // Display frame RowLayout { id: frameBG spacing: 1 anchors.fill: parent visible: root.showFrame && image.status === Image.Ready Rectangle { color: "black" opacity: 0.5 Layout.fillWidth: true Layout.fillHeight: true } Item { Layout.preferredHeight: image.paintedHeight Layout.preferredWidth: image.paintedWidth } Rectangle { color: "black" opacity: 0.5 Layout.fillWidth: true Layout.fillHeight: true } } Image { id: image asynchronous: true smooth: false anchors.fill: parent visible: false // Preserve aspect fit while display ratio is aligned with image ratio, crop otherwise fillMode: width / height >= imageRatio ? Image.PreserveAspectFit : Image.PreserveAspectCrop autoTransform: true } // Custom shader for displaying undistorted images with principal point correction ShaderEffect { id: shader anchors.centerIn: parent visible: image.status === Image.Ready width: image.paintedWidth height: image.paintedHeight property variant src: image property variant uvCenterOffset fragmentShader: "qrc:/shaders/ImageOverlay.frag.qsb" } } ================================================ FILE: meshroom/ui/qml/Viewer3D/Inspector3D.qml ================================================ import QtQuick import QtQuick.Controls import QtQuick.Layouts import Qt3D.Core 2.6 import Qt3D.Render 2.6 import Controls 1.0 import MaterialIcons 2.2 import Utils 1.0 FloatingPane { id: root implicitWidth: 200 property int renderMode: 2 property Grid3D grid: null property MediaLibrary mediaLibrary property Camera camera property var uigraph: null signal mediaFocusRequest(var index) signal mediaRemoveRequest(var index) signal nodeActivated(var node) padding: 0 MouseArea { anchors.fill: parent onWheel: function(wheel) { wheel.accepted = true } } ColumnLayout { anchors.fill: parent spacing: 4 ExpandableGroup { id: displayGroup Layout.fillWidth: true title: "DISPLAY" GridLayout { width: parent.width columns: 2 columnSpacing: 6 rowSpacing: 3 Flow { Layout.columnSpan: 2 Layout.fillWidth: true visible: displayGroup.expanded spacing: 1 MaterialToolButton { text: MaterialIcons.grid_on ToolTip.text: "Display Grid" checked: Viewer3DSettings.displayGrid onClicked: Viewer3DSettings.displayGrid = !Viewer3DSettings.displayGrid } MaterialToolButton { text: MaterialIcons.adjust checked: Viewer3DSettings.displayGizmo ToolTip.text: "Display Trackball" onClicked: Viewer3DSettings.displayGizmo = !Viewer3DSettings.displayGizmo } MaterialToolButton { text: MaterialIcons.call_merge ToolTip.text: "Display Origin" checked: Viewer3DSettings.displayOrigin onClicked: Viewer3DSettings.displayOrigin = !Viewer3DSettings.displayOrigin } MaterialToolButton { text: MaterialIcons.light_mode ToolTip.text: "Display Light Controller" checked: Viewer3DSettings.displayLightController onClicked: Viewer3DSettings.displayLightController = !Viewer3DSettings.displayLightController } } MaterialLabel { text: MaterialIcons.grain padding: 2 visible: displayGroup.expanded } RowLayout { visible: displayGroup.expanded Slider { Layout.fillWidth: true; from: 0; to: 5; stepSize: 0.001 value: Viewer3DSettings.pointSize onValueChanged: Viewer3DSettings.pointSize = value ToolTip.text: "Point Size: " + value.toFixed(2) ToolTip.visible: hovered || pressed ToolTip.delay: 150 } MaterialToolButton { text: MaterialIcons.center_focus_strong ToolTip.text: "Fixed Point Size" font.pointSize: 10 padding: 3 checked: Viewer3DSettings.fixedPointSize onClicked: Viewer3DSettings.fixedPointSize = !Viewer3DSettings.fixedPointSize } } MaterialLabel { text: MaterialIcons.videocam padding: 2 visible: displayGroup.expanded } Slider { visible: displayGroup.expanded value: Viewer3DSettings.cameraScale from: 0 to: 2 stepSize: 0.01 Layout.fillWidth: true padding: 0 onMoved: Viewer3DSettings.cameraScale = value ToolTip.text: "Camera Scale: " + value.toFixed(2) ToolTip.visible: hovered || pressed ToolTip.delay: 150 } } } ExpandableGroup { id: cameraGroup Layout.fillWidth: true title: "CAMERA" ColumnLayout { width: parent.width // Image/Camera synchronization Flow { Layout.fillWidth: true visible: cameraGroup.expanded spacing: 2 // Synchronization MaterialToolButton { id: syncViewpointCamera enabled: _currentScene && mediaLibrary.count > 0 ? _currentScene.sfmReport : false text: MaterialIcons.linked_camera ToolTip.text: "View Through The Active Camera" checkable: true checked: enabled && Viewer3DSettings.syncViewpointCamera onCheckedChanged: Viewer3DSettings.syncViewpointCamera = !Viewer3DSettings.syncViewpointCamera } // Image Overlay controls RowLayout { visible: syncViewpointCamera.enabled && Viewer3DSettings.syncViewpointCamera spacing: 2 // Activation MaterialToolButton { text: MaterialIcons.image ToolTip.text: "Image Overlay" checked: Viewer3DSettings.viewpointImageOverlay onClicked: Viewer3DSettings.viewpointImageOverlay = !Viewer3DSettings.viewpointImageOverlay } // Opacity Slider { visible: Viewer3DSettings.showViewpointImageOverlay implicitWidth: 60 from: 0 to: 100 value: Viewer3DSettings.viewpointImageOverlayOpacity * 100 onValueChanged: Viewer3DSettings.viewpointImageOverlayOpacity = value / 100 ToolTip.text: "Image Opacity: " + Viewer3DSettings.viewpointImageOverlayOpacity.toFixed(2) ToolTip.visible: hovered || pressed ToolTip.delay: 100 } } // Image Frame control MaterialToolButton { visible: syncViewpointCamera.enabled && Viewer3DSettings.showViewpointImageOverlay enabled: Viewer3DSettings.syncViewpointCamera text: MaterialIcons.crop_free ToolTip.text: "Frame Overlay" checked: Viewer3DSettings.viewpointImageFrame onClicked: Viewer3DSettings.viewpointImageFrame = !Viewer3DSettings.viewpointImageFrame } } ColumnLayout { Layout.fillWidth: true spacing: 2 visible: cameraGroup.expanded RowLayout { Layout.fillHeight: true spacing: 2 MaterialToolButton { id: resectionIdButton text: MaterialIcons.switch_video ToolTip.text: "Timeline Of Camera Reconstruction Groups" ToolTip.visible: hovered enabled: Viewer3DSettings.resectionIdCount checked: enabled && Viewer3DSettings.displayResectionIds onClicked: { Viewer3DSettings.displayResectionIds = !Viewer3DSettings.displayResectionIds Viewer3DSettings.resectionId = Viewer3DSettings.resectionIdCount resectionIdSlider.value = Viewer3DSettings.resectionId } onEnabledChanged: { Viewer3DSettings.resectionId = Viewer3DSettings.resectionIdCount resectionIdSlider.value = Viewer3DSettings.resectionId if (!enabled) { Viewer3DSettings.displayResectionIds = false } } } Slider { id: resectionIdSlider value: Viewer3DSettings.resectionId from: 0 to: Viewer3DSettings.resectionIdCount stepSize: 1 onMoved: Viewer3DSettings.resectionId = value Layout.fillWidth: true leftPadding: 2 rightPadding: 2 visible: Viewer3DSettings.displayResectionIds } Label { text: resectionIdSlider.value + "/" + Viewer3DSettings.resectionIdCount color: palette.text visible: Viewer3DSettings.displayResectionIds } } RowLayout { spacing: 10 Layout.fillWidth: true Layout.margins: 2 Layout.alignment: Qt.AlignHCenter | Qt.AlignVCenter MaterialToolLabel { iconText: MaterialIcons.stop label.text: { var id = undefined // Ensure there are entries in resectionGroups and a valid resectionId before accessing anything if (Viewer3DSettings.resectionId !== undefined && Viewer3DSettings.resectionGroups && Viewer3DSettings.resectionGroups.length > 0) id = Math.min(Viewer3DSettings.resectionId, Viewer3DSettings.resectionIdCount) if (id !== undefined && Viewer3DSettings.resectionGroups[id] !== undefined) return Viewer3DSettings.resectionGroups[id] return 0 } labelIconColor: palette.text ToolTip.text: "Number Of Cameras In Current Resection Group" visible: Viewer3DSettings.displayResectionIds } MaterialToolLabel { iconText: MaterialIcons.auto_awesome_motion label.text: { let currentCameras = 0 if (Viewer3DSettings.resectionGroups) { for (let i = 0; i <= Viewer3DSettings.resectionIdCount; i++) { if (i <= Viewer3DSettings.resectionId) currentCameras += Viewer3DSettings.resectionGroups[i] } } return currentCameras } labelIconColor: palette.text ToolTip.text: "Number Of Cumulated Cameras" visible: Viewer3DSettings.displayResectionIds } MaterialToolLabel { iconText: MaterialIcons.videocam label.text: { let totalCameras = 0 if (Viewer3DSettings.resectionGroups) { for (let i = 0; i <= Viewer3DSettings.resectionIdCount; i++) { totalCameras += Viewer3DSettings.resectionGroups[i] } } return totalCameras } labelIconColor: palette.text ToolTip.text: "Total Number Of Cameras" visible: Viewer3DSettings.displayResectionIds } } } } } // 3D Scene content Group { title: "SCENE" Layout.fillWidth: true Layout.fillHeight: true sidePadding: 0 toolBarContent: MaterialToolButton { id: infoButton ToolTip.text: "Media Info" text: MaterialIcons.info_outline font.pointSize: 10 implicitHeight: parent.height checkable: true checked: true } ColumnLayout { anchors.fill: parent SearchBar { id: searchBar Layout.minimumWidth: 150 Layout.fillWidth: true Layout.rightMargin: 10 Layout.leftMargin: 10 } ListView { id: mediaListView Layout.fillHeight: true Layout.fillWidth: true clip: true spacing: 4 ScrollBar.vertical: MScrollBar { id: scrollBar } onCountChanged: { if (mediaListView.count === 0) { Viewer3DSettings.resectionIdCount = 0 } } currentIndex: -1 Connections { target: uigraph function onSelectedNodeChanged() { mediaListView.currentIndex = -1 } } Connections { target: mediaLibrary function onLoadRequest(idx) { mediaListView.positionViewAtIndex(idx, ListView.Visible) } } model: SortFilterDelegateModel { model: mediaLibrary.model sortRole: "" filters: [{role: "label", value: searchBar.text}] delegate: MouseArea { id: mediaDelegate // Add mediaLibrary.count in the binding to ensure 'entity' // is re-evaluated when mediaLibrary delegates are modified property bool loading: model.status === SceneLoader.Loading property bool hovered: model.attribute ? (uigraph ? uigraph.hoveredNode === model.attribute.node : false) : containsMouse property bool isSelectedNode: model.attribute ? (uigraph ? uigraph.selectedNode === model.attribute.node : false) : false onIsSelectedNodeChanged: updateCurrentIndex() function updateCurrentIndex() { if (isSelectedNode) { mediaListView.currentIndex = index } // If the index is updated, and the resection ID count is available, update every resection-related variable: // this covers the changes of index that occur when a node whose output is already loaded in the 3D viewer is // clicked/double-clicked, and when the active entry is removed from the list. if (model.resectionIdCount) { Viewer3DSettings.resectionIdCount = model.resectionIdCount Viewer3DSettings.resectionGroups = model.resectionGroups Viewer3DSettings.resectionId = model.resectionId resectionIdSlider.value = model.resectionId } } height: childrenRect.height width: { if (parent != null) return parent.width - scrollBar.width return undefined } hoverEnabled: true onEntered: { if (model.attribute) uigraph.hoveredNode = model.attribute.node } onExited: { if (model.attribute) uigraph.hoveredNode = null } onClicked: function(mouse) { if (model.attribute) uigraph.selectedNode = model.attribute.node else uigraph.selectedNode = null if (mouse.button == Qt.RightButton) contextMenu.popup() mediaListView.currentIndex = index // Update the resection ID-related objects based on the active model Viewer3DSettings.resectionIdCount = model.resectionIdCount Viewer3DSettings.resectionGroups = model.resectionGroups Viewer3DSettings.resectionId = model.resectionId resectionIdSlider.value = model.resectionId } onDoubleClicked: { model.visible = true; nodeActivated(model.attribute.node); } Connections { target: resectionIdSlider function onValueChanged() { model.resectionId = resectionIdSlider.value } } RowLayout { width: parent.width spacing: 2 property string src: model.source onSrcChanged: focusAnim.restart() Connections { target: mediaListView function onCountChanged() { mediaDelegate.updateCurrentIndex() } } // Current/selected element indicator Rectangle { Layout.fillHeight: true width: 2 color: { if (mediaListView.currentIndex == index || mediaDelegate.isSelectedNode) return label.palette.highlight; if (mediaDelegate.hovered) return Qt.darker(label.palette.highlight, 1.5); return "transparent"; } } // Media visibility/loading control MaterialToolButton { Layout.alignment: Qt.AlignTop Layout.fillHeight: true text: model.visible ? MaterialIcons.visibility : MaterialIcons.visibility_off font.pointSize: 10 ToolTip.text: model.visible ? "Hide" : model.requested ? "Show" : model.valid ? "Load and Show" : "Load and Show when Available" flat: true opacity: model.visible ? 1.0 : 0.6 onClicked: { if (hoverArea.modifiers & Qt.ControlModifier) mediaLibrary.solo(index); else model.visible = !model.visible } // Handle modifiers on button click MouseArea { id: hoverArea property int modifiers anchors.fill: parent hoverEnabled: true onPositionChanged: function(mouse) { modifiers = mouse.modifiers } onExited: modifiers = Qt.NoModifier onPressed: function(mouse) { modifiers = mouse.modifiers; mouse.accepted = false; } } } // BoundingBox visibility (if meshing node) MaterialToolButton { visible: model.hasBoundingBox enabled: model.visible Layout.alignment: Qt.AlignTop Layout.fillHeight: true text: MaterialIcons.transform_ font.pointSize: 10 ToolTip.text: model.displayBoundingBox ? "Hide BBox" : "Show BBox" flat: true opacity: model.visible ? (model.displayBoundingBox ? 1.0 : 0.6) : 0.6 onClicked: { model.displayBoundingBox = !model.displayBoundingBox } } // Transform visibility (if SfMTransform node) MaterialToolButton { visible: model.hasTransform enabled: model.visible Layout.alignment: Qt.AlignTop Layout.fillHeight: true text: MaterialIcons._3d_rotation font.pointSize: 10 ToolTip.text: model.displayTransform ? "Hide Gizmo" : "Show Gizmo" flat: true opacity: model.visible ? (model.displayTransform ? 1.0 : 0.6) : 0.6 onClicked: { model.displayTransform = !model.displayTransform } } // Media label and info Item { implicitHeight: childrenRect.height Layout.fillWidth: true Layout.alignment: Qt.AlignTop ColumnLayout { id: centralLayout width: parent.width spacing: 1 Label { id: label Layout.fillWidth: true leftPadding: 0 rightPadding: 0 topPadding: 3 bottomPadding: topPadding text: model.label color: palette.text opacity: model.valid ? 1.0 : 0.6 elide: Text.ElideMiddle font.weight: mediaListView.currentIndex === index ? Font.DemiBold : Font.Normal background: Rectangle { Connections { target: mediaLibrary function onLoadRequest(idx) { if (idx === index) focusAnim.restart() } } ColorAnimation on color { id: focusAnim from: label.palette.highlight to: "transparent" duration: 2000 } } } Item { visible: infoButton.checked Layout.fillWidth: true implicitHeight: childrenRect.height Flow { width: parent.width spacing: 4 visible: model.status === SceneLoader.Ready RowLayout { spacing: 1 visible: model.vertexCount MaterialLabel { text: MaterialIcons.grain } Label { text: Format.intToString(model.vertexCount) color: palette.text } } RowLayout { spacing: 1 visible: model.faceCount MaterialLabel { text: MaterialIcons.details; rotation: -180 } Label { text: Format.intToString(model.faceCount) color: palette.text } } RowLayout { spacing: 1 visible: model.cameraCount MaterialLabel { text: MaterialIcons.videocam } Label { text: model.cameraCount color: palette.text } } RowLayout { spacing: 1 visible: model.textureCount MaterialLabel { text: MaterialIcons.texture } Label { text: model.textureCount color: palette.text } } } } } Menu { id: contextMenu MenuItem { text: "Open Containing Folder" enabled: model.valid onTriggered: Qt.openUrlExternally(Filepath.dirname(model.source)) } MenuItem { text: "Copy Path" onTriggered: Clipboard.setText(Filepath.normpath(model.source)) } MenuSeparator {} MenuItem { text: model.requested ? "Unload Media" : "Load Media" enabled: model.valid onTriggered: model.requested = !model.requested } } } // Remove media from library button MaterialToolButton { id: removeButton Layout.alignment: Qt.AlignTop Layout.fillHeight: true visible: !loading && mediaDelegate.containsMouse text: MaterialIcons.clear font.pointSize: 10 ToolTip.text: "Remove" ToolTip.delay: 500 onClicked: mediaLibrary.remove(index) } // Media loading indicator BusyIndicator { visible: loading running: visible padding: removeButton.padding implicitHeight: implicitWidth implicitWidth: removeButton.width } } } } } } } } } ================================================ FILE: meshroom/ui/qml/Viewer3D/Locator3D.qml ================================================ import QtQuick import Qt3D.Core 2.6 import Qt3D.Render 2.6 import Qt3D.Extras 2.15 import Utils 1.0 // Locator Entity { id: locatorEntity components: [ GeometryRenderer { primitiveType: GeometryRenderer.Lines geometry: Geometry { Attribute { id: locatorPosition attributeType: Attribute.VertexAttribute vertexBaseType: Attribute.Float vertexSize: 3 count: 6 name: defaultPositionAttributeName buffer: Buffer { data: new Float32Array([ 0.0, 0.001, 0.0, 1.0, 0.001, 0.0, 0.0, 0.001, 0.0, 0.0, 1.001, 0.0, 0.0, 0.001, 0.0, 0.0, 0.001, 1.0 ]) } } Attribute { attributeType: Attribute.VertexAttribute vertexBaseType: Attribute.Float vertexSize: 3 count: 6 name: defaultColorAttributeName buffer: Buffer { data: new Float32Array([ Colors.red.r, Colors.red.g, Colors.red.b, Colors.red.r, Colors.red.g, Colors.red.b, Colors.green.r, Colors.green.g, Colors.green.b, Colors.green.r, Colors.green.g, Colors.green.b, Colors.blue.r, Colors.blue.g, Colors.blue.b, Colors.blue.r, Colors.blue.g, Colors.blue.b ]) } } boundingVolumePositionAttribute: locatorPosition } }, PerVertexColorMaterial {}, Transform { id: locatorTransform } ] } ================================================ FILE: meshroom/ui/qml/Viewer3D/MaterialSwitcher.qml ================================================ import Qt3D.Core 2.6 import Qt3D.Render 2.6 import Qt3D.Input 2.6 import Qt3D.Extras 2.15 import QtQuick import Utils 1.0 import "Materials" /** * MaterialSwitcher is an Entity that can change its parent's material * by setting the 'mode' property. */ Entity { id: root objectName: "MaterialSwitcher" property int mode: 2 property string diffuseMap: "" property color ambient: "#AAA" property real shininess property color specular property color diffuseColor: "#AAA" readonly property alias activeMaterial: m.material QtObject { id: m property Material material onMaterialChanged: { // Remove previous material(s) removeComponentsByType(parent, "Material") Scene3DHelper.addComponent(root.parent, material) } } function removeComponentsByType(entity, type) { if (!entity) return for (var i = 0; i < entity.components.length; ++i) { if (entity.components[i].toString().indexOf(type) !== -1) { Scene3DHelper.removeComponent(entity, entity.components[i]) } } } StateGroup { id: modeState state: Viewer3DSettings.renderModes[mode].name states: [ State { name: "Solid" PropertyChanges { target: m; material: solid } }, State { name: "Wireframe" PropertyChanges { target: m; material: wireframe } }, State { name: "Textured" PropertyChanges { target: m; // "textured" material resolution order: diffuse map > vertex color data > no color info material: diffuseMap ? textured : (Scene3DHelper.vertexColorCount(root.parent) ? colored : solid) } }, State { name: "Spherical Harmonics" PropertyChanges { target: m; material: shMaterial } } ] } // Solid and Textured modes could and should be using the same material // but get random shader errors (No shader program found for DNA) // when toggling between a color and a texture for the diffuse property DiffuseSpecularMaterial { id: solid objectName: "SolidMaterial" ambient: root.ambient shininess: root.shininess specular: root.specular diffuse: root.diffuseColor } PerVertexColorMaterial { id: colored objectName: "VertexColorMaterial" } DiffuseMapMaterial { id: textured objectName: "TexturedMaterial" ambient: root.ambient shininess: root.shininess specular: root.specular diffuse: TextureLoader { magnificationFilter: Texture.Linear mirrored: false source: diffuseMap } } WireframeMaterial { id: wireframe objectName: "WireframeMaterial" effect: WireframeEffect {} } SphericalHarmonicsMaterial { id: shMaterial objectName: "SphericalHarmonicsMaterial" effect: SphericalHarmonicsEffect {} shlSource: Filepath.stringToUrl(Viewer3DSettings.shlFile) displayNormals: Viewer3DSettings.displayNormals } } ================================================ FILE: meshroom/ui/qml/Viewer3D/Materials/SphericalHarmonicsEffect.qml ================================================ import Qt3D.Core 2.6 import Qt3D.Render 2.6 Effect { id: root parameters: [ Parameter { name: "shCoeffs[0]"; value: [] }, Parameter { name: "displayNormals"; value: false } ] techniques: [ Technique { graphicsApiFilter { api: GraphicsApiFilter.RHI profile: GraphicsApiFilter.CoreProfile majorVersion: 1 minorVersion: 0 } filterKeys: [ FilterKey { name: "renderingStyle"; value: "forward" } ] renderPasses: [ RenderPass { shaderProgram: ShaderProgram { vertexShaderCode: loadSource(Qt.resolvedUrl("shaders/SphericalHarmonics.vert")) fragmentShaderCode: loadSource(Qt.resolvedUrl("shaders/SphericalHarmonics.frag")) } } ] } ] } ================================================ FILE: meshroom/ui/qml/Viewer3D/Materials/SphericalHarmonicsMaterial.qml ================================================ import Qt3D.Core 2.6 import Qt3D.Render 2.6 import Utils 1.0 Material { id: root /// Source file containing coefficients property url shlSource /// Spherical Harmonics coefficients (array of 9 vector3d) property var coefficients: noCoeffs /// Whether to display normals instead of SH property bool displayNormals: false // Default coefficients (uniform magenta) readonly property var noCoeffs: [ Qt.vector3d(0.0, 0.0, 0.0), Qt.vector3d(0.0, 0.0, 0.0), Qt.vector3d(0.0, 0.0, 0.0), Qt.vector3d(1.0, 0.0, 1.0), Qt.vector3d(0.0, 0.0, 0.0), Qt.vector3d(0.0, 0.0, 0.0), Qt.vector3d(0.0, 0.0, 0.0), Qt.vector3d(0.0, 0.0, 0.0), Qt.vector3d(0.0, 0.0, 0.0) ] effect: SphericalHarmonicsEffect {} onShlSourceChanged: { if (!shlSource) { coefficients = noCoeffs return } Request.get(Filepath.urlToString(shlSource), function(xhr) { if (xhr.readyState === XMLHttpRequest.DONE) { var coeffs = [] var lines = xhr.responseText.split("\n") lines.forEach(function(l) { var lineCoeffs = [] l.split(" ").forEach(function(v) { if (v) lineCoeffs.push(v) }) if (lineCoeffs.length == 3) coeffs.push(Qt.vector3d(lineCoeffs[0], lineCoeffs[1], lineCoeffs[2])) }) if (coeffs.length == 9) { coefficients = coeffs } else { console.warn("Invalid SHL file: " + shlSource + " with " + coeffs.length + " coefficients.") coefficients = noCoeffs } } }) } parameters: [ Parameter { name: "shCoeffs[0]"; value: coefficients }, Parameter { name: "displayNormals"; value: displayNormals } ] } ================================================ FILE: meshroom/ui/qml/Viewer3D/Materials/WireframeEffect.qml ================================================ import Qt3D.Core 2.6 import Qt3D.Render 2.6 Effect { id: root parameters: [ ] techniques: [ Technique { graphicsApiFilter { api: GraphicsApiFilter.RHI profile: GraphicsApiFilter.CoreProfile majorVersion: 1 minorVersion: 0 } filterKeys: [ FilterKey { name: "renderingStyle"; value: "forward" } ] parameters: [ ] renderPasses: [ RenderPass { shaderProgram: ShaderProgram { vertexShaderCode: loadSource(Qt.resolvedUrl("shaders/robustwireframe.vert")) fragmentShaderCode: loadSource(Qt.resolvedUrl("shaders/robustwireframe.frag")) } } ] } ] } ================================================ FILE: meshroom/ui/qml/Viewer3D/Materials/WireframeMaterial.qml ================================================ import Qt3D.Core 2.6 import Qt3D.Render 2.6 Material { id: root effect: WireframeEffect {} parameters: [ ] } ================================================ FILE: meshroom/ui/qml/Viewer3D/Materials/shaders/SphericalHarmonics.frag ================================================ #version 450 core layout(location = 0) in vec3 normal; layout(location = 0) out vec4 fragColor; layout(std140, binding = 0) uniform qt3d_render_view_uniforms { mat4 viewMatrix; mat4 projectionMatrix; mat4 uncorrectedProjectionMatrix; mat4 clipCorrectionMatrix; mat4 viewProjectionMatrix; mat4 inverseViewMatrix; mat4 inverseProjectionMatrix; mat4 inverseViewProjectionMatrix; mat4 viewportMatrix; mat4 inverseViewportMatrix; vec4 textureTransformMatrix; vec3 eyePosition; float aspectRatio; float gamma; float exposure; float time; }; layout(std140, binding = 1) uniform qt3d_command_uniforms { mat4 modelMatrix; mat4 inverseModelMatrix; mat4 modelViewMatrix; mat3 modelNormalMatrix; mat4 inverseModelViewMatrix; mat4 mvp; mat4 inverseModelViewProjectionMatrix; }; layout(std140, binding = 2) uniform input_uniforms { vec3 shCoeffs[9]; bool displayNormals; }; vec3 resolveSH_Opt(vec3 premulCoefficients[9], vec3 dir) { vec3 result = premulCoefficients[0] * dir.x; result += premulCoefficients[1] * dir.y; result += premulCoefficients[2] * dir.z; result += premulCoefficients[3]; vec3 dirSq = dir * dir; result += premulCoefficients[4] * (dir.x * dir.y); result += premulCoefficients[5] * (dir.x * dir.z); result += premulCoefficients[6] * (dir.y * dir.z); result += premulCoefficients[7] * (dirSq.x - dirSq.y); result += premulCoefficients[8] * (3 * dirSq.z - 1); return result; } void main() { if(displayNormals) { // Display normals mode fragColor = vec4(normal, 1.0); } else { // Calculate the color from spherical harmonics coeffs fragColor = vec4(resolveSH_Opt(shCoeffs, normal), 1.0); } } ================================================ FILE: meshroom/ui/qml/Viewer3D/Materials/shaders/SphericalHarmonics.vert ================================================ #version 450 core layout(location = 0) in vec3 vertexPosition; layout(location = 1) in vec3 vertexNormal; layout(location = 0) out vec3 normal; layout(std140, binding = 0) uniform qt3d_render_view_uniforms { mat4 viewMatrix; mat4 projectionMatrix; mat4 uncorrectedProjectionMatrix; mat4 clipCorrectionMatrix; mat4 viewProjectionMatrix; mat4 inverseViewMatrix; mat4 inverseProjectionMatrix; mat4 inverseViewProjectionMatrix; mat4 viewportMatrix; mat4 inverseViewportMatrix; vec4 textureTransformMatrix; vec3 eyePosition; float aspectRatio; float gamma; float exposure; float time; }; layout(std140, binding = 1) uniform qt3d_command_uniforms { mat4 modelMatrix; mat4 inverseModelMatrix; mat4 modelViewMatrix; mat3 modelNormalMatrix; mat4 inverseModelViewMatrix; mat4 mvp; mat4 inverseModelViewProjectionMatrix; }; void main() { normal = vertexNormal; gl_Position = mvp * vec4(vertexPosition, 1.0); } ================================================ FILE: meshroom/ui/qml/Viewer3D/Materials/shaders/robustwireframe.frag ================================================ #version 450 #extension GL_NV_fragment_shader_barycentric : require layout(location = 0) out vec4 fragColor; void main() { vec3 barycentric = gl_BaryCoordNV; float mindist = min(min(barycentric.x, barycentric.y), barycentric.z); if (mindist < 0.05) { fragColor = vec4(1.0, 1.0, 1.0, 1.0); } } ================================================ FILE: meshroom/ui/qml/Viewer3D/Materials/shaders/robustwireframe.vert ================================================ #version 450 core layout(location = 0) in vec3 vertexPosition; layout(std140, binding = 0) uniform qt3d_render_view_uniforms { mat4 viewMatrix; mat4 projectionMatrix; mat4 uncorrectedProjectionMatrix; mat4 clipCorrectionMatrix; mat4 viewProjectionMatrix; mat4 inverseViewMatrix; mat4 inverseProjectionMatrix; mat4 inverseViewProjectionMatrix; mat4 viewportMatrix; mat4 inverseViewportMatrix; vec4 textureTransformMatrix; vec3 eyePosition; float aspectRatio; float gamma; float exposure; float time; float yUpInNDC; float yUpInFBO; }; layout(std140, binding = 1) uniform qt3d_command_uniforms { mat4 modelMatrix; mat4 inverseModelMatrix; mat4 modelViewMatrix; mat3 modelNormalMatrix; mat4 inverseModelViewMatrix; mat4 modelViewProjection; mat4 inverseModelViewProjectionMatrix; }; void main() { gl_Position = projectionMatrix * viewMatrix * modelMatrix * vec4( vertexPosition, 1.0 ); } ================================================ FILE: meshroom/ui/qml/Viewer3D/MediaCache.qml ================================================ import QtQuick import Qt3D.Core 2.6 import Qt3D.Render 2.6 import Utils 1.0 Entity { id: root /// Enable debug mode (show cache entity with a scale applied) property bool debug: false enabled: false // Disabled entity components: [ Transform { id: transform scale: 1 } ] StateGroup { states: [ State { when: root.debug name: "debug" PropertyChanges { target: root enabled: true } PropertyChanges { target: transform scale: 0.2 } } ] } property int cacheSize: 2 property var mediaCache: {[]} /// The current number of managed entities function currentSize() { return Object.keys(mediaCache).length } /// Whether the cache contains an entity for the given source function contains(source) { return mediaCache[source] !== undefined } /// Add an entity to the cache function add(source, object) { if (!Filepath.exists(source)) return false if (contains(source)) return true if (debug) { console.log("[cache] add: " + source) } mediaCache[source] = object object.parent = root // Remove oldest entry in cache if (currentSize() > cacheSize) shrink() return true } /// Pop an entity from the cache based on its source function pop(source) { if (!contains(source)) return undefined var obj = mediaCache[source] delete mediaCache[source] if (debug) { console.log("[cache] pop: " + source) } // Delete cached obj if file does not exist on disk anymore if (!Filepath.exists(source)) { if (debug) { console.log("[cache] destroy: " + source) } obj.destroy() obj = undefined } return obj } /// Remove and destroy an entity from cache function destroyEntity(source) { var obj = pop(source) if (obj) obj.destroy() } // Shrink cache to fit max size function shrink() { while (currentSize() > cacheSize) destroyEntity(Object.keys(mediaCache)[0]) } // Clear cache and destroy all managed entities function clear() { Object.keys(mediaCache).forEach(function(key) { destroyEntity(key) }) } } ================================================ FILE: meshroom/ui/qml/Viewer3D/MediaLibrary.qml ================================================ import QtQuick import Qt3D.Core 2.6 import Qt3D.Render 2.6 import Utils 1.0 /** * MediaLibrary is an Entity that loads and manages a list of 3D media. * It also uses an internal cache to instantly reload media. */ Entity { id: root readonly property alias model: m.mediaModel property int renderMode property bool pickingEnabled: false readonly property alias count: instantiator.count // Number of instantiated media delegates // For TransformGizmo in BoundingBox property DefaultCameraController sceneCameraController property Layer frontLayerComponent property var window /// Camera to consider for positioning property Camera camera: null /// True while at least one media is being loaded readonly property bool loading: { for (var i = 0; i < m.mediaModel.count; ++i) { if (m.mediaModel.get(i).status === SceneLoader.Loading) return true } return false } signal clicked(var pick) signal loadRequest(var idx) QtObject { id: m property ListModel mediaModel: ListModel { dynamicRoles: true } property var sourceToEntity: ({}) readonly property var mediaElement: ({ "source": "", "valid": true, "label": "", "visible": true, "hasBoundingBox": false, // For Meshing node only "displayBoundingBox": true, // For Meshing node only "hasTransform": false, // For SfMTransform node only "displayTransform": true, // For SfMTransform node only "section": "", "attribute": null, "entity": null, "requested": true, "vertexCount": 0, "faceCount": 0, "cameraCount": 0, "textureCount": 0, "resectionIdCount": 0, "resectionId": 0, "resectionGroups": [], "status": SceneLoader.None }) } function makeElement(values) { return Object.assign({}, JSON.parse(JSON.stringify(m.mediaElement)), values) } function ensureVisible(source) { var idx = find(source) if (idx === -1) return m.mediaModel.get(idx).visible = true loadRequest(idx) } function find(source) { for (var i = 0; i < m.mediaModel.count; ++i) { var elt = m.mediaModel.get(i) if (elt.source === source || elt.attribute === source) return i } return -1 } function load(filepath, label = undefined) { var pathStr = Filepath.urlToString(filepath) if (!Filepath.exists(pathStr)) { console.warn("Media Error: File " + pathStr + " does not exist.") return } // File already loaded, return if (m.sourceToEntity[pathStr]) { ensureVisible(pathStr) return } // Add file to the internal ListModel m.mediaModel.append( makeElement({ "source": pathStr, "label": label ? label : Filepath.basename(pathStr), "section": "External" })) } function view(attribute) { if (m.sourceToEntity[attribute]) { ensureVisible(attribute) return } var section = attribute.node.label // Add file to the internal ListModel m.mediaModel.append( makeElement({ "label": `${section}.${attribute.label}`, "section": section, "attribute": attribute })) } function remove(index) { // Remove corresponding entry from model m.mediaModel.remove(index) } /// Get entity based on source function entity(source) { return sourceToEntity[source] } function entityAt(index) { return instantiator.objectAt(index) } function solo(index) { for (var i = 0; i < m.mediaModel.count; ++i) { m.mediaModel.setProperty(i, "visible", i === index) } } function clear() { m.mediaModel.clear() cache.clear() } // Cache that keeps in memory the last unloaded 3D media MediaCache { id: cache } NodeInstantiator { id: instantiator model: m.mediaModel delegate: Entity { id: instantiatedEntity property alias fullyInstantiated: mediaLoader.fullyInstantiated readonly property alias modelSource: mediaLoader.modelSource // Get the node property var currentNode: model.attribute ? model.attribute.node : null property string nodeType: currentNode ? currentNode.nodeType: null // Specific properties to the MESHING node (declared and initialized for every Entity anyway) property bool hasBoundingBox: { if (currentNode && currentNode.hasAttribute("useBoundingBox")) // Can have a BoundingBox return currentNode.attribute("useBoundingBox").value return false } onHasBoundingBoxChanged: model.hasBoundingBox = hasBoundingBox property bool displayBoundingBox: model.displayBoundingBox // Specific properties to the SFMTRANSFORM node (declared and initialized for every Entity anyway) property bool hasTransform: { if (nodeType === "SfMTransform" && currentNode.attribute("method")) // Can have a Transform return currentNode.attribute("method").value === "manual" return false } onHasTransformChanged: model.hasTransform = hasTransform property bool displayTransform: model.displayTransform // Create the medias MediaLoader { id: mediaLoader cameraPickingEnabled: !sceneCameraController.pickingActive // Whether MediaLoader has been fully instantiated by the NodeInstantiator property bool fullyInstantiated: false // Explicitly store some attached model properties for outside use and ease binding readonly property var attribute: model.attribute readonly property int idx: index readonly property var modelSource: attribute || model.source readonly property bool visible: model.visible // Multi-step binding to ensure MediaLoader source is properly // updated when needed, whether raw source is valid or not // Raw source path property string rawSource: attribute ? attribute.value : model.source // Whether dependencies are statified (applies for output/connected input attributes only) readonly property bool dependencyReady: { if (attribute) { const rootAttribute = attribute.isLink ? attribute.inputRootLink : attribute if (rootAttribute.isOutput) return rootAttribute.node.globalStatus === "SUCCESS" } return true // Is an input param without link (so no dependency) or an external file } // Source based on raw source + dependency status property string currentSource: dependencyReady ? rawSource : "" // Source based on currentSource + "requested" property property string finalSource: model.requested ? currentSource : "" // To use only if we want to draw the input source and not the current node output (Warning: to use with caution) // There is maybe a better way to do this to avoid overwriting bindings which should be readonly properties function drawInputSource() { rawSource = Qt.binding(() => instantiatedEntity.currentNode ? instantiatedEntity.currentNode.attribute("input").value: "") currentSource = Qt.binding(() => rawSource) finalSource = Qt.binding(() => rawSource) } camera: root.camera renderMode: root.renderMode enabled: visible property bool alive: attribute ? attribute.node.alive : false onAliveChanged: { if (!alive && index >= 0) remove(index) } // 'visible' property drives media loading request onVisibleChanged: { // Always request media loading if visible if (model.visible) model.requested = true // Only cancel loading request if media is not valid // (a media will not be unloaded if already loaded, only hidden) else if (!model.valid) model.requested = false } function updateCacheAndModel(forceRequest) { // Don't cache explicitly unloaded media if (model.requested && object && dependencyReady) { // Cache current object if (cache.add(Filepath.urlToString(mediaLoader.source), object)) object = null } updateModel(forceRequest) } function updateModel(forceRequest) { // Update model's source path if input is an attribute if (attribute) { model.source = rawSource } // Auto-restore entity if raw source is in cache model.requested = forceRequest || (!model.valid && model.requested) || cache.contains(rawSource) model.valid = Filepath.exists(rawSource) && dependencyReady } Component.onCompleted: { // Keep 'source' -> 'entity' reference m.sourceToEntity[modelSource] = instantiatedEntity // Always request media loading when delegate has been created updateModel(true) // If external media failed to open, remove element from model if (!attribute && !object) remove(index) } onCurrentSourceChanged: { updateCacheAndModel(false) // Avoid the bounding box to disappear when we move it after a mesh already computed if (instantiatedEntity.hasBoundingBox && !currentSource) model.visible = true } onFinalSourceChanged: { // Update media visibility // (useful if media was explicitly unloaded or hidden but loaded back from cache) model.visible = model.requested var cachedObject = cache.pop(rawSource) cached = cachedObject !== undefined if (cached) { object = cachedObject // Only change cached object parent if mediaLoader has been fully instantiated // by the NodeInstantiator; otherwise re-parenting will fail silently and the object will disappear... // see "onFullyInstantiatedChanged" and parent NodeInstantiator's "onObjectAdded" if (fullyInstantiated) { object.parent = mediaLoader } } mediaLoader.source = Filepath.stringToUrl(finalSource) if (object) { // Bind media info to corresponding model roles // (test for object validity to avoid error messages right after object has been deleted) var boundProperties = ["vertexCount", "faceCount", "cameraCount", "textureCount", "resectionIdCount", "resectionId", "resectionGroups"] boundProperties.forEach(function(prop) { model[prop] = Qt.binding(function() { return object ? object[prop] : 0 }) }) } else if (finalSource && status === Component.Ready) { // Source was valid but no loader was created, remove element // Check if component is ready to avoid removing element from the model before adding instance to the node remove(index) } } onFullyInstantiatedChanged: { // Delayed reparenting of object coming from the cache if (object) object.parent = mediaLoader } onStatusChanged: { model.status = status // Remove model entry for external media that failed to load if (status === SceneLoader.Error && !model.attribute) remove(index) } components: [ ObjectPicker { enabled: mediaLoader.enabled && pickingEnabled hoverEnabled: false onClicked: function(pick) { root.clicked(pick) } } ] } // Transform: display a TransformGizmo for SfMTransform node only // Note: use a NodeInstantiator to evaluate if the current node is a SfMTransform node and if the transform mode is set to Manual NodeInstantiator { id: sfmTransformGizmoInstantiator active: instantiatedEntity.hasTransform model: 1 SfMTransformGizmo { id: sfmTransformGizmoEntity sceneCameraController: root.sceneCameraController frontLayerComponent: root.frontLayerComponent window: root.window currentSfMTransformNode: instantiatedEntity.currentNode enabled: mediaLoader.visible && instantiatedEntity.displayTransform Component.onCompleted: { mediaLoader.drawInputSource() // Because we are sure we want to show the input in MANUAL mode only Scene3DHelper.addComponent(mediaLoader, sfmTransformGizmoEntity.objectTransform) // Add the transform to the media to see real-time transformations } } } // BoundingBox: display bounding box for MESHING computation // Note: use a NodeInstantiator to evaluate if the current node is a MESHING node and if the checkbox is active NodeInstantiator { id: boundingBoxInstantiator active: instantiatedEntity.hasBoundingBox model: 1 MeshingBoundingBox { sceneCameraController: root.sceneCameraController frontLayerComponent: root.frontLayerComponent window: root.window currentMeshingNode: instantiatedEntity.currentNode enabled: mediaLoader.visible && instantiatedEntity.displayBoundingBox } } } onObjectAdded: function(index, object) { // Notify object that it is now fully instantiated object.fullyInstantiated = true // We only update the actually displayed attributes if the media.source is an attribute. // A string mean that a file has been dropped on the viewer3D if (object.modelSource &&object.modelSource.hasOwnProperty("desc")) { _currentScene.displayedAttrs3D.append(object.modelSource) } } onObjectRemoved: function(index, object) { if (m.sourceToEntity[object.modelSource]) delete m.sourceToEntity[object.modelSource] // We only update the actually displayed attributes if the media.source is an attribute. // A string mean that a file has been dropped on the viewer3D if(object.modelSource && object.modelSource.hasOwnProperty("desc")) { _currentScene.displayedAttrs3D.remove(object.modelSource) } } } } ================================================ FILE: meshroom/ui/qml/Viewer3D/MediaLoader.qml ================================================ import QtQuick import Qt3D.Core 2.6 import Qt3D.Render 2.6 import Qt3D.Extras 2.15 import QtQuick.Scene3D 2.6 import "Materials" import Utils 1.0 /** * MediaLoader provides a single entry point for 3D media loading. * It encapsulates all available plugins/loaders. */ Entity { id: root property url source property bool loading: false property int status: SceneLoader.None property var object: null property int renderMode /// Scene's current camera property Camera camera: null property bool cached: false property bool cameraPickingEnabled: false onSourceChanged: { if (cached) { root.status = SceneLoader.Ready return } // Clear previously created object if any if (object) { object.destroy() object = null } var component = undefined status = SceneLoader.Loading if (!Filepath.exists(source)) { status = SceneLoader.None return } switch (Filepath.extension(source)) { case ".ply": if ((Filepath.extension(Filepath.removeExtension(source))) == ".pc") { if (Viewer3DSettings.supportSfmData) component = sfmDataLoaderEntityComponent } else { component = sceneLoaderEntityComponent } break case ".abc": case ".json": case ".sfm": if (Viewer3DSettings.supportSfmData) component = sfmDataLoaderEntityComponent break case ".exr": if (Viewer3DSettings.supportDepthMap) component = exrLoaderComponent break case ".obj": case ".stl": default: component = sceneLoaderEntityComponent break } // Media loader available if (component) { object = component.createObject(root, {"source": source}) } } Component { id: sceneLoaderEntityComponent MediaLoaderEntity { id: sceneLoaderEntity objectName: "SceneLoader" components: [ SceneLoader { source: parent.source onStatusChanged: function(status) { if (status == SceneLoader.Ready) { textureCount = sceneLoaderPostProcess(sceneLoaderEntity) faceCount = Scene3DHelper.faceCount(sceneLoaderEntity) } root.status = status; } } ] } } Component { id: sfmDataLoaderEntityComponent MediaLoaderEntity { id: sfmDataLoaderEntity Component.onCompleted: { var obj = Viewer3DSettings.sfmDataLoaderComp.createObject(sfmDataLoaderEntity, { "source": source, "fixedPointSize": Qt.binding(function() { return Viewer3DSettings.fixedPointSize }), "pointSize": Qt.binding(function() { return Viewer3DSettings.pointSize }), "locatorScale": Qt.binding(function() { return Viewer3DSettings.cameraScale }), "cameraPickingEnabled": Qt.binding(function() { return root.enabled && root.cameraPickingEnabled }), "resectionId": Qt.binding(function() { return Viewer3DSettings.resectionId }), "displayResections": Qt.binding(function() { return Viewer3DSettings.displayResectionIds }), "syncPickedViewId": Qt.binding(function() { return Viewer3DSettings.syncWithPickedViewId }) }); obj.statusChanged.connect(function() { if (obj.status === SceneLoader.Ready) { for (var i = 0; i < obj.pointClouds.length; ++i) { vertexCount += Scene3DHelper.vertexCount(obj.pointClouds[i]) } cameraCount = obj.spawnCameraSelectors() } Viewer3DSettings.resectionIdCount = obj.countResectionIds() Viewer3DSettings.resectionGroups = obj.countResectionGroups(Viewer3DSettings.resectionIdCount + 1) resectionIdCount = Viewer3DSettings.resectionIdCount resectionGroups = Viewer3DSettings.resectionGroups resectionId = Viewer3DSettings.resectionIdCount root.status = obj.status }) obj.cameraSelected.connect( function(viewId) { if (viewId) { obj.selectedViewId = viewId } } ) } } } Component { id: exrLoaderComponent MediaLoaderEntity { id: exrLoaderEntity Component.onCompleted: { var fSize = Filepath.fileSizeMB(source) if (fSize > 500) { // Do not load images that are larger than 500MB console.warn("Viewer3D: Do not load the EXR in 3D as the file size is too large: " + fSize + "MB") root.status = SceneLoader.Error return } // EXR loading strategy: // - [1] as a depth map var obj = Viewer3DSettings.depthMapLoaderComp.createObject( exrLoaderEntity, { "source": source }) if (obj.status === SceneLoader.Ready) { faceCount = Scene3DHelper.faceCount(obj) root.status = SceneLoader.Ready return } // - [2] as an environment map obj.destroy() root.status = SceneLoader.Loading obj = Qt.createComponent("EnvironmentMapEntity.qml").createObject( exrLoaderEntity, { "source": source, "position": Qt.binding(function() { return root.camera.position }) }) obj.statusChanged.connect(function() { root.status = obj.status; }) } } } Component { id: materialSwitcherComponent MaterialSwitcher { } } // Remove automatically created DiffuseMapMaterial and // instantiate a MaterialSwitcher instead. Returns the faceCount function sceneLoaderPostProcess(rootEntity) { var materials = Scene3DHelper.findChildrenByProperty(rootEntity, "diffuse") var entities = [] var texCount = 0 materials.forEach(function(mat) { entities.push(mat.parent) }) entities.forEach(function(entity) { var mats = [] var componentsToRemove = [] // Create as many MaterialSwitcher as individual materials for this entity // NOTE: we let each MaterialSwitcher modify the components of the entity // and therefore remove the default material spawned by the sceneLoader for (var i = 0; i < entity.components.length; ++i) { var comp = entity.components[i] // Handle DiffuseMapMaterials created by SceneLoader if (comp.toString().indexOf("QDiffuseMapMaterial") > -1) { // Store material definition var m = { "diffuseMap": comp.diffuse.data[0].source, "shininess": comp.shininess, "specular": comp.specular, "ambient": comp.ambient, "mode": root.renderMode } texCount++ mats.push(m) componentsToRemove.push(comp) } if (comp.toString().indexOf("QPhongMaterial") > -1) { // Create MaterialSwitcher with default colors mats.push({}) componentsToRemove.push(comp) } } mats.forEach(function(m) { // Create a material switcher for each material definition var matSwitcher = materialSwitcherComponent.createObject(entity, m) matSwitcher.mode = Qt.binding(function() { return root.renderMode }) }) // Remove replaced components componentsToRemove.forEach(function(comp) { Scene3DHelper.removeComponent(entity, comp) }) }) return texCount } } ================================================ FILE: meshroom/ui/qml/Viewer3D/MediaLoaderEntity.qml ================================================ import QtQuick import Qt3D.Core 2.6 /** * MediaLoaderEntity provides a unified interface for accessing statistics * of a 3D media independently from the way it was loaded. */ Entity { property url source /// Number of vertices property int vertexCount /// Number of faces property int faceCount /// Number of cameras property int cameraCount /// Number of textures property int textureCount /// Number of resection IDs property int resectionIdCount /// Current resection ID property int resectionId /// Groups of cameras based on resection IDs property var resectionGroups } ================================================ FILE: meshroom/ui/qml/Viewer3D/MeshingBoundingBox.qml ================================================ import Qt3D.Core 2.6 import Qt3D.Render 2.6 import Qt3D.Input 2.6 import Qt3D.Extras 2.15 import QtQuick /** * BoundingBox entity for Meshing node. Used to define the area to reconstruct. * Simple box controlled by a gizmo to give easy and visual representation. */ Entity { id: root property DefaultCameraController sceneCameraController property Layer frontLayerComponent property var window property var currentMeshingNode: null enabled: true EntityWithGizmo { id: boundingBoxEntity sceneCameraController: root.sceneCameraController frontLayerComponent: root.frontLayerComponent window: root.window // Update node meshing slider values when the gizmo has changed: translation, rotation, scale, type transformGizmo.onGizmoChanged: function(translation, rotation, scale, type) { var rotationEuler_cv = Qt.vector3d(rotation.x, rotation.y, rotation.z) var rotation_gl = Transformations3DHelper.convertRotationFromCV2GL(rotationEuler_cv) switch (type) { case TransformGizmo.Type.TRANSLATION: { _currentScene.setAttribute( root.currentMeshingNode.attribute("boundingBox.bboxTranslation"), JSON.stringify([translation.x, -translation.y, -translation.z]) ) break } case TransformGizmo.Type.ROTATION: { _currentScene.setAttribute( root.currentMeshingNode.attribute("boundingBox.bboxRotation"), JSON.stringify([rotation_gl.x, rotation_gl.y, rotation_gl.z]) ) break } case TransformGizmo.Type.SCALE: { _currentScene.setAttribute( root.currentMeshingNode.attribute("boundingBox.bboxScale"), JSON.stringify([scale.x, scale.y, scale.z]) ) break } case TransformGizmo.Type.ALL: { _currentScene.setAttribute( root.currentMeshingNode.attribute("boundingBox"), JSON.stringify([ [translation.x, -translation.y, -translation.z], [rotation_gl.x, rotation_gl.y, rotation_gl.z], [scale.x, scale.y, scale.z] ]) ) break } } } // Translation values from node (vector3d because this is the type of QTransform.translation) property var nodeTranslation : Qt.vector3d( root.currentMeshingNode ? root.currentMeshingNode.attribute("boundingBox.bboxTranslation.x").value : 0, root.currentMeshingNode ? -root.currentMeshingNode.attribute("boundingBox.bboxTranslation.y").value : 0, root.currentMeshingNode ? -root.currentMeshingNode.attribute("boundingBox.bboxTranslation.z").value : 0 ) // Rotation values from node (3 separated values because QTransform stores Euler angles like this) property var nodeRotationX: { var rx = root.currentMeshingNode ? root.currentMeshingNode.attribute("boundingBox.bboxRotation.x").value : 0 var ry = root.currentMeshingNode ? root.currentMeshingNode.attribute("boundingBox.bboxRotation.y").value : 0 var rz = root.currentMeshingNode ? root.currentMeshingNode.attribute("boundingBox.bboxRotation.z").value : 0 var rotationEuler_cv = Qt.vector3d(rx, ry, rz) var rotation_gl = Transformations3DHelper.convertRotationFromCV2GL(rotationEuler_cv) return rotation_gl.x } property var nodeRotationY: { var rx = root.currentMeshingNode ? root.currentMeshingNode.attribute("boundingBox.bboxRotation.x").value : 0 var ry = root.currentMeshingNode ? root.currentMeshingNode.attribute("boundingBox.bboxRotation.y").value : 0 var rz = root.currentMeshingNode ? root.currentMeshingNode.attribute("boundingBox.bboxRotation.z").value : 0 var rotationEuler_cv = Qt.vector3d(rx, ry, rz) var rotation_gl = Transformations3DHelper.convertRotationFromCV2GL(rotationEuler_cv) return rotation_gl.y } property var nodeRotationZ: { var rx = root.currentMeshingNode ? root.currentMeshingNode.attribute("boundingBox.bboxRotation.x").value : 0 var ry = root.currentMeshingNode ? root.currentMeshingNode.attribute("boundingBox.bboxRotation.y").value : 0 var rz = root.currentMeshingNode ? root.currentMeshingNode.attribute("boundingBox.bboxRotation.z").value : 0 var rotationEuler_cv = Qt.vector3d(rx, ry, rz) var rotation_gl = Transformations3DHelper.convertRotationFromCV2GL(rotationEuler_cv) return rotation_gl.z } // Scale values from node (vector3d because this is the type of QTransform.scale3D) property var nodeScale: Qt.vector3d( root.currentMeshingNode ? root.currentMeshingNode.attribute("boundingBox.bboxScale.x").value : 1, root.currentMeshingNode ? root.currentMeshingNode.attribute("boundingBox.bboxScale.y").value : 1, root.currentMeshingNode ? root.currentMeshingNode.attribute("boundingBox.bboxScale.z").value : 1 ) // Automatically evaluate the Transform: value is taken from the node OR from the actual modification if the gizmo is moved by mouse. // When the gizmo has changed (with mouse), the new values are set to the node, the priority is given back to the node and the Transform is re-evaluated once with those values. transformGizmo.gizmoDisplayTransform.translation: transformGizmo.focusGizmoPriority ? transformGizmo.gizmoDisplayTransform.translation : nodeTranslation transformGizmo.gizmoDisplayTransform.rotationX: transformGizmo.focusGizmoPriority ? transformGizmo.gizmoDisplayTransform.rotationX : nodeRotationX transformGizmo.gizmoDisplayTransform.rotationY: transformGizmo.focusGizmoPriority ? transformGizmo.gizmoDisplayTransform.rotationY : nodeRotationY transformGizmo.gizmoDisplayTransform.rotationZ: transformGizmo.focusGizmoPriority ? transformGizmo.gizmoDisplayTransform.rotationZ : nodeRotationZ transformGizmo.objectTransform.scale3D: transformGizmo.focusGizmoPriority ? transformGizmo.objectTransform.scale3D : nodeScale // The entity BoundingBox { transform: boundingBoxEntity.objectTransform } } } ================================================ FILE: meshroom/ui/qml/Viewer3D/SfMTransformGizmo.qml ================================================ import Qt3D.Core 2.6 import Qt3D.Render 2.6 import Qt3D.Input 2.6 import Qt3D.Extras 2.15 import QtQuick /** * Gizmo for SfMTransform node. * Uses EntityWithGizmo wrapper because we should not instantiate TransformGizmo alone. */ Entity { id: root property DefaultCameraController sceneCameraController property Layer frontLayerComponent property var window property var currentSfMTransformNode: null enabled: true readonly property alias objectTransform: sfmTranformGizmoEntity.objectTransform // The Transform the object should use EntityWithGizmo { id: sfmTranformGizmoEntity sceneCameraController: root.sceneCameraController frontLayerComponent: root.frontLayerComponent window: root.window uniformScale: true // We want to make uniform scale transformations // Update node SfMTransform slider values when the gizmo has changed: translation, rotation, scale, type transformGizmo.onGizmoChanged: { switch (type) { case TransformGizmo.Type.TRANSLATION: { _currentScene.setAttribute( root.currentSfMTransformNode.attribute("manualTransform.manualTranslation"), JSON.stringify([translation.x, translation.y, translation.z]) ) break } case TransformGizmo.Type.ROTATION: { _currentScene.setAttribute( root.currentSfMTransformNode.attribute("manualTransform.manualRotation"), JSON.stringify([rotation.x, rotation.y, rotation.z]) ) break } case TransformGizmo.Type.SCALE: { // Only one scale is needed since the scale is uniform _currentScene.setAttribute( root.currentSfMTransformNode.attribute("manualTransform.manualScale"), scale.x ) break } case TransformGizmo.Type.ALL: { _currentScene.setAttribute( root.currentSfMTransformNode.attribute("manualTransform"), JSON.stringify([ [translation.x, translation.y, translation.z], [rotation.x, rotation.y, rotation.z], scale.x ]) ) break } } } // Translation values from node (vector3d because this is the type of QTransform.translation) property var nodeTranslation : Qt.vector3d( root.currentSfMTransformNode ? root.currentSfMTransformNode.attribute("manualTransform.manualTranslation.x").value : 0, root.currentSfMTransformNode ? root.currentSfMTransformNode.attribute("manualTransform.manualTranslation.y").value : 0, root.currentSfMTransformNode ? root.currentSfMTransformNode.attribute("manualTransform.manualTranslation.z").value : 0 ) // Rotation values from node (3 separated values because QTransform stores Euler angles like this) property var nodeRotationX: root.currentSfMTransformNode ? root.currentSfMTransformNode.attribute("manualTransform.manualRotation.x").value : 0 property var nodeRotationY: root.currentSfMTransformNode ? root.currentSfMTransformNode.attribute("manualTransform.manualRotation.y").value : 0 property var nodeRotationZ: root.currentSfMTransformNode ? root.currentSfMTransformNode.attribute("manualTransform.manualRotation.z").value : 0 // Scale value from node (simple number because we use uniform scale) property var nodeScale: root.currentSfMTransformNode ? root.currentSfMTransformNode.attribute("manualTransform.manualScale").value : 1 // Automatically evaluate the Transform: value is taken from the node OR from the actual modification if the gizmo is moved by mouse. // When the gizmo has changed (with mouse), the new values are set to the node, the priority is given back to the node and the Transform is re-evaluated once with those values. transformGizmo.gizmoDisplayTransform.translation: transformGizmo.focusGizmoPriority ? transformGizmo.gizmoDisplayTransform.translation : nodeTranslation transformGizmo.gizmoDisplayTransform.rotationX: transformGizmo.focusGizmoPriority ? transformGizmo.gizmoDisplayTransform.rotationX : nodeRotationX transformGizmo.gizmoDisplayTransform.rotationY: transformGizmo.focusGizmoPriority ? transformGizmo.gizmoDisplayTransform.rotationY : nodeRotationY transformGizmo.gizmoDisplayTransform.rotationZ: transformGizmo.focusGizmoPriority ? transformGizmo.gizmoDisplayTransform.rotationZ : nodeRotationZ transformGizmo.objectTransform.scale3D: transformGizmo.focusGizmoPriority ? transformGizmo.objectTransform.scale3D : Qt.vector3d(nodeScale, nodeScale, nodeScale) } } ================================================ FILE: meshroom/ui/qml/Viewer3D/SfmDataLoader.qml ================================================ import QtQuick import Qt3D.Core 2.6 import Qt3D.Render 2.6 import Qt3D.Extras 2.15 import SfmDataEntity 1.0 /** * Support for SfMData files in Qt3D. * Create this component dynamically to test for SfmDataEntity plugin availability. */ SfmDataEntity { id: root property bool cameraPickingEnabled: true property bool syncPickedViewId: false // Filter out non-reconstructed cameras skipHidden: true signal cameraSelected(var viewId) Connections { target: _currentScene function onSelectedViewIdChanged() { root.cameraSelected(_currentScene.selectedViewId) } function onSelectedViewpointChanged() { root.cameraSelected(_currentScene.pickedViewId) } } function spawnCameraSelectors() { var validCameras = 0; // Spawn camera selector for each camera for (var i = 0; i < root.cameras.length; ++i) { var cam = root.cameras[i]; // retrieve view id var viewId = cam.viewId; if (viewId === undefined) continue; camSelectionComponent.createObject(cam, {"viewId": viewId}); dummyCamSelectionComponent.createObject(cam, {"viewId": viewId}); validCameras++; } return validCameras; } function countResectionIds() { var maxResectionId = 0 for (var i = 0; i < root.cameras.length; i++) { var cam = root.cameras[i] var resectionId = cam.resectionId // 4294967295 = UINT_MAX, which might occur if the value is undefined on the C++ side if (resectionId === undefined || resectionId === 4294967295) continue if (resectionId > maxResectionId) maxResectionId = resectionId } return maxResectionId } function countResectionGroups(resectionIdCount) { var arr = Array(resectionIdCount).fill(0) for (var i = 0; i < root.cameras.length; i++) { var cam = root.cameras[i] var resectionId = cam.resectionId // 4294967295 = UINT_MAX, which might occur if the value is undefined on the C++ side if (resectionId === undefined || resectionId === 4294967295) continue arr[resectionId] = arr[resectionId] + 1 } return arr } SystemPalette { id: activePalette } // Camera selection display only Component { id: dummyCamSelectionComponent Entity { id: dummyCamSelector property string viewId property color customColor: Qt.hsva((parseInt(viewId) / 255.0) % 1.0, 0.3, 1.0, 1.0) property real extent: cameraPickingEnabled ? 0.2 : 0 components: [ // Use cuboid to represent the camera Transform { translation: Qt.vector3d(0, 0, 0.5 * cameraBack.zExtent) }, CuboidMesh { id: cameraBack xExtent: parent.extent yExtent: xExtent zExtent: xExtent * 0.2 }, PhongMaterial{ id: mat ambient: _currentScene && (viewId === _currentScene.selectedViewId || (viewId === _currentScene.pickedViewId && syncPickedViewId)) ? activePalette.highlight : customColor // "#CCC" } ] } } // Camera selection picking only Component { id: camSelectionComponent Entity { id: camSelector property string viewId property color customColor: Qt.hsva((parseInt(viewId) / 255.0) % 1.0, 0.3, 1.0, 1.0) property real extent: cameraPickingEnabled ? 0.5 : 0 components: [ // Use cuboid to represent the camera Transform { translation: Qt.vector3d(0, 0, 0.5 * cameraBack.zExtent) }, CuboidMesh { id: cameraBack xExtent: parent.extent yExtent: xExtent zExtent: xExtent }, ObjectPicker { id: cameraPicker property point pos onPressed: function(pick) { pos = pick.position; pick.accepted = (pick.buttons & Qt.LeftButton) && cameraPickingEnabled } onReleased: function(pick) { const delta = Qt.point(Math.abs(pos.x - pick.position.x), Math.abs(pos.y - pick.position.y)) // Only trigger picking when mouse has not moved between press and release if (delta.x + delta.y < 4) { _currentScene.selectedViewId = camSelector.viewId } } } ] } } } ================================================ FILE: meshroom/ui/qml/Viewer3D/TrackballGizmo.qml ================================================ import Qt3D.Core 2.6 import Qt3D.Render 2.6 import Qt3D.Input 2.6 import Qt3D.Extras 2.15 import QtQuick Entity { id: root property real beamRadius: 0.0075 property real beamLength: 1 property int slices: 10 property int rings: 50 property color centerColor: "white" property color xColor: "red" property color yColor: "green" property color zColor: "blue" property real alpha: 1.0 property Transform transform: Transform {} components: [transform] Behavior on alpha { PropertyAnimation { duration: 100 } } // Gizmo center Entity { components: [ SphereMesh { radius: beamRadius * 4}, PhongMaterial { ambient: "#FFF" shininess: 0.2 diffuse: centerColor specular: centerColor } ] } // X, Y, Z rings NodeInstantiator { model: 3 Entity { components: [ TorusMesh { radius: root.beamLength minorRadius: root.beamRadius slices: root.slices rings: root.rings }, DiffuseSpecularMaterial { ambient: { switch (index) { case 0: return xColor; case 1: return yColor; case 2: return zColor; } } shininess: 0 diffuse: Qt.rgba(0.6, 0.6, 0.6, root.alpha) }, Transform { rotationY: index == 0 ? 90 : 0 rotationX: index == 1 ? 90 : 0 } ] } } } ================================================ FILE: meshroom/ui/qml/Viewer3D/TransformGizmo.qml ================================================ import Qt3D.Core 2.6 import Qt3D.Render 2.6 import Qt3D.Input 2.6 import Qt3D.Extras 2.15 import Qt3D.Logic 2.6 import QtQuick import QtQuick.Controls import Utils 1.0 /** * Simple transformation gizmo entirely made with Qt3D entities. * Uses Python Transformations3DHelper to compute matrices. * This TransformGizmo entity should only be instantiated in EntityWithGizmo entity which is its wrapper. * It means, to use it for a specified application, make sure to instantiate EntityWithGizmo. */ Entity { id: root property Camera camera property var windowSize property Layer frontLayerComponent // Used to draw gizmo on top of everything property var window readonly property alias gizmoScale: gizmoScaleLookSlider.value property bool uniformScale: false // By default, the scale is not uniform property bool focusGizmoPriority: false // If true, it is used to give the priority to the current transformation (and not to a upper-level binding) property Transform gizmoDisplayTransform: Transform { id: gizmoDisplayTransform scale: root.gizmoScale * (camera.position.minus(gizmoDisplayTransform.translation)).length() // The gizmo needs a constant apparent size } // Component the object controlled by the gizmo must use property Transform objectTransform : Transform { translation: gizmoDisplayTransform.translation rotation: gizmoDisplayTransform.rotation scale3D: Qt.vector3d(1,1,1) } signal pickedChanged(bool pressed) signal gizmoChanged(var translation, var rotation, var scale, int type) function emitGizmoChanged(type) { const translation = gizmoDisplayTransform.translation // Position in space const rotation = Qt.vector3d(gizmoDisplayTransform.rotationX, gizmoDisplayTransform.rotationY, gizmoDisplayTransform.rotationZ) // Euler angles const scale = objectTransform.scale3D // Scale of the object gizmoChanged(translation, rotation, scale, type) root.focusGizmoPriority = false } components: [gizmoDisplayTransform, mouseHandler, frontLayerComponent] /***** ENUMS *****/ enum Axis { X, Y, Z } enum Direction { Forward, Backward } enum Type { TRANSLATION, ROTATION, SCALE, ALL } function convertAxisEnum(axis) { switch (axis) { case TransformGizmo.Axis.X: return Qt.vector3d(1,0,0) case TransformGizmo.Axis.Y: return Qt.vector3d(0,1,0) case TransformGizmo.Axis.Z: return Qt.vector3d(0,0,1) } } function convertDirectionEnum(direction) { switch (direction) { case TransformGizmo.Direction.Forward: return 1 case TransformGizmo.Direction.Backward: return -1 } } function convertTypeEnum(type) { switch (type) { case TransformGizmo.Type.TRANSLATION: return "TRANSLATION" case TransformGizmo.Type.ROTATION: return "ROTATION" case TransformGizmo.Type.SCALE: return "SCALE" case TransformGizmo.Type.ALL: return "ALL" } } /***** TRANSFORMATIONS (using local vars) *****/ /** * @brief Translate locally the gizmo and the object. * * @remarks * To make local translation, we need to recompute a new matrix. * Update gizmoDisplayTransform's matrix and all its properties while avoiding the override of translation property. * Update objectTransform in the same time thanks to binding on translation property. * * @param initialModelMatrix object containing position, rotation and scale matrices + rotation quaternion * @param translateVec vector3d used to make the local translation */ function doRelativeTranslation(initialModelMatrix, translateVec) { Transformations3DHelper.relativeLocalTranslate( gizmoDisplayTransform, initialModelMatrix.position, initialModelMatrix.rotation, initialModelMatrix.scale, translateVec ) } /** * @brief Rotate the gizmo and the object around a specific axis. * * @remarks * To make local rotation around an axis, we need to recompute a new matrix from a quaternion. * Update gizmoDisplayTransform's matrix and all its properties while avoiding the override of rotation, rotationX, rotationY and rotationZ properties. * Update objectTransform in the same time thanks to binding on rotation property. * * @param initialModelMatrix object containing position, rotation and scale matrices + rotation quaternion * @param axis vector3d describing the axis to rotate around * @param degree angle of rotation in degrees */ function doRelativeRotation(initialModelMatrix, axis, degree) { Transformations3DHelper.relativeLocalRotate( gizmoDisplayTransform, initialModelMatrix.position, initialModelMatrix.quaternion, initialModelMatrix.scale, axis, degree ) } /** * @brief Scale the object relatively to its current scale. * * @remarks * To change scale of the object, we need to recompute a new matrix to avoid overriding bindings. * Update objectTransform properties only (gizmoDisplayTransform is not affected). * * @param initialModelMatrix object containing position, rotation and scale matrices + rotation quaternion * @param scaleVec vector3d used to make the relative scale */ function doRelativeScale(initialModelMatrix, scaleVec) { Transformations3DHelper.relativeLocalScale( objectTransform, initialModelMatrix.position, initialModelMatrix.rotation, initialModelMatrix.scale, scaleVec ) } /** * @brief Reset the translation of the gizmo and the object. * * @remarks * Update gizmoDisplayTransform's matrix and all its properties while avoiding the override of translation property. * Update objectTransform in the same time thanks to binding on translation property. */ function resetTranslation() { const mat = gizmoDisplayTransform.matrix const newMat = Qt.matrix4x4( mat.m11, mat.m12, mat.m13, 0, mat.m21, mat.m22, mat.m23, 0, mat.m31, mat.m32, mat.m33, 0, mat.m41, mat.m42, mat.m43, 1 ) gizmoDisplayTransform.setMatrix(newMat) } /** * @brief Reset the rotation of the gizmo and the object. * * @remarks * Update gizmoDisplayTransform's quaternion while avoiding the override of rotationX, rotationY and rotationZ properties. * Update objectTransform in the same time thanks to binding on rotation property. * Here, we can change the rotation property (but not rotationX, rotationY and rotationZ because they can be used in upper-level bindings). * * @note * We could implement a way of changing the matrix instead of overriding rotation (quaternion) property. */ function resetRotation() { gizmoDisplayTransform.rotation = Qt.quaternion(1,0,0,0) } /** * @brief Reset the scale of the object. * * @remarks * To reset the scale, we make the difference of the current one to 1 and recompute the matrix. * Like this, we kind of apply an inverse scale transformation. * It prevents overriding scale3D property (because it can be used in upper-level binding). */ function resetScale() { const modelMat = Transformations3DHelper.modelMatrixToMatrices(objectTransform.matrix) const scaleDiff = Qt.vector3d( -(objectTransform.scale3D.x - 1), -(objectTransform.scale3D.y - 1), -(objectTransform.scale3D.z - 1) ) doRelativeScale(modelMat, scaleDiff) } /***** DEVICES *****/ MouseDevice { id: mouseSourceDevice } MouseHandler { id: mouseHandler sourceDevice: enabled ? mouseSourceDevice : null property var objectPicker: null property bool enabled: false onPositionChanged: function(mouse) { if (objectPicker && objectPicker.button === Qt.LeftButton) { root.focusGizmoPriority = true // Get the selected axis const pickedAxis = convertAxisEnum(objectPicker.gizmoAxis) // TRANSLATION, SCALE or SURFACE MOVE transformation = SURFACE MOVE is a combination of TRANSLATION AND SCALE if (objectPicker.gizmoType === TransformGizmo.Type.TRANSLATION || objectPicker.gizmoType === TransformGizmo.Type.SCALE || objectPicker.gizmoType === TransformGizmo.Type.ALL) { // Compute the vector PickedPosition -> CurrentMousePoint const pickedPosition = objectPicker.screenPoint const mouseVector = Qt.vector2d((mouse.x - pickedPosition.x), -(mouse.y - pickedPosition.y)) // Transform the positive picked axis vector from World Coord to Screen Coord const gizmoLocalPointOnAxis = gizmoDisplayTransform.matrix.times(Qt.vector4d(pickedAxis.x, pickedAxis.y, pickedAxis.z, 1)) const gizmoCenterPoint = gizmoDisplayTransform.matrix.times(Qt.vector4d(0, 0, 0, 1)) const screenPoint2D = Transformations3DHelper.pointFromWorldToScreen(gizmoLocalPointOnAxis, camera, windowSize) const screenCenter2D = Transformations3DHelper.pointFromWorldToScreen(gizmoCenterPoint, camera, windowSize) const screenAxisVector = Qt.vector2d(screenPoint2D.x - screenCenter2D.x, -(screenPoint2D.y - screenCenter2D.y)) // Get the cosinus of the angle from the screenAxisVector to the mouseVector // It will be used as a intensity factor const cosAngle = screenAxisVector.dotProduct(mouseVector) / (screenAxisVector.length() * mouseVector.length()) const offset = cosAngle * mouseVector.length() / objectPicker.scaleUnit // Do the transformation if (objectPicker.gizmoType === TransformGizmo.Type.TRANSLATION && offset !== 0) { doRelativeTranslation(objectPicker.modelMatrix, pickedAxis.times(offset)) // Do a translation from the initial Object Model Matrix when we picked the gizmo } else if (objectPicker.gizmoType === TransformGizmo.Type.SCALE && offset !== 0) { if (root.uniformScale) doRelativeScale(objectPicker.modelMatrix, Qt.vector3d(1, 1, 1).times(offset)) // Do a uniform scale from the initial Object Model Matrix when we picked the gizmo else doRelativeScale(objectPicker.modelMatrix, pickedAxis.times(offset)) // Do a scale on one axis from the initial Object Model Matrix when we picked the gizmo } else if (objectPicker.gizmoType === TransformGizmo.Type.ALL && offset !== 0) { const sign = convertDirectionEnum(objectPicker.gizmoDirection) doRelativeScale(objectPicker.modelMatrix, pickedAxis.times(sign * offset/2)) doRelativeTranslation(objectPicker.modelMatrix, pickedAxis.times(offset/2)) } return } // ROTATION transformation else if (objectPicker.gizmoType === TransformGizmo.Type.ROTATION) { // Get Screen Coordinates of the gizmo center const gizmoCenterPoint = gizmoDisplayTransform.matrix.times(Qt.vector4d(0, 0, 0, 1)) const screenCenter2D = Transformations3DHelper.pointFromWorldToScreen(gizmoCenterPoint, camera, root.windowSize) // Get the vector screenCenter2D -> PickedPosition const originalVector = Qt.vector2d(objectPicker.screenPoint.x - screenCenter2D.x, -(objectPicker.screenPoint.y - screenCenter2D.y)) // Compute the vector screenCenter2D -> CurrentMousePoint const mouseVector = Qt.vector2d(mouse.x - screenCenter2D.x, -(mouse.y - screenCenter2D.y)) // Get the angle from the originalVector to the mouseVector const angle = Math.atan2(-originalVector.y * mouseVector.x + originalVector.x * mouseVector.y, originalVector.x * mouseVector.x + originalVector.y * mouseVector.y) * 180 / Math.PI // Get the orientation of the gizmo in function of the camera const gizmoLocalAxisVector = gizmoDisplayTransform.matrix.times(Qt.vector4d(pickedAxis.x, pickedAxis.y, pickedAxis.z, 0)) const gizmoToCameraVector = camera.position.toVector4d().minus(gizmoCenterPoint) const orientation = gizmoLocalAxisVector.dotProduct(gizmoToCameraVector) > 0 ? 1 : -1 if (angle !== 0) doRelativeRotation(objectPicker.modelMatrix, pickedAxis, angle * orientation) // Do a rotation from the initial Object Model Matrix when we picked the gizmo return } } if (objectPicker && objectPicker.button === Qt.RightButton) { resetMenu.popup(window) } } onReleased: function(mouse) { if (objectPicker && mouse.button === Qt.LeftButton) { const type = objectPicker.gizmoType objectPicker = null // To prevent going again in the onPositionChanged emitGizmoChanged(type) } } } Menu { id: resetMenu MenuItem { text: "Reset Translation" onTriggered: { resetTranslation() emitGizmoChanged(TransformGizmo.Type.TRANSLATION) } } MenuItem { text: "Reset Rotation" onTriggered: { resetRotation() emitGizmoChanged(TransformGizmo.Type.ROTATION) } } MenuItem { text: "Reset Scale" onTriggered: { resetScale() emitGizmoChanged(TransformGizmo.Type.SCALE) } } MenuItem { text: "Reset All" onTriggered: { resetTranslation() resetRotation() resetScale() emitGizmoChanged(TransformGizmo.Type.ALL) } } MenuItem { text: "Gizmo Scale Look" Slider { id: gizmoScaleLookSlider anchors.right: parent.right anchors.rightMargin: 10 height: parent.height width: parent.width * 0.40 from: 0.06 to: 0.30 stepSize: 0.01 value: 0.15 } } } /***** GIZMO'S BASIC COMPONENTS *****/ Entity { id: centerSphereEntity components: [centerSphereMesh, centerSphereMaterial, frontLayerComponent] SphereMesh { id: centerSphereMesh radius: 0.04 rings: 8 slices: 8 } PhongMaterial { id: centerSphereMaterial property color base: "white" ambient: base shininess: 0.2 } } // AXIS GIZMO INSTANTIATOR => X, Y and Z NodeInstantiator { model: 3 Entity { id: axisContainer property int axis : { switch (index) { case 0: return TransformGizmo.Axis.X case 1: return TransformGizmo.Axis.Y case 2: return TransformGizmo.Axis.Z } } property color baseColor: { switch (axis) { case TransformGizmo.Axis.X: return "#e63b55" // Red case TransformGizmo.Axis.Y: return "#83c414" // Green case TransformGizmo.Axis.Z: return "#3387e2" // Blue } } property real lineRadius: 0.011 // SCALE ENTITY Entity { id: scaleEntity Entity { id: axisCylinder components: [cylinderMesh, cylinderTransform, scaleMaterial, frontLayerComponent] CylinderMesh { id: cylinderMesh length: 0.5 radius: axisContainer.lineRadius rings: 2 slices: 16 } Transform { id: cylinderTransform matrix: { const offset = cylinderMesh.length / 2 + centerSphereMesh.radius const m = Qt.matrix4x4() switch (axis) { case TransformGizmo.Axis.X: { m.translate(Qt.vector3d(offset, 0, 0)) m.rotate(90, Qt.vector3d(0, 0, 1)) break } case TransformGizmo.Axis.Y: { m.translate(Qt.vector3d(0, offset, 0)) break } case TransformGizmo.Axis.Z: { m.translate(Qt.vector3d(0, 0, offset)) m.rotate(90, Qt.vector3d(1, 0, 0)) break } } return m } } } Entity { id: axisScaleBox components: [cubeScaleMesh, cubeScaleTransform, scaleMaterial, scalePicker, frontLayerComponent] CuboidMesh { id: cubeScaleMesh property real edge: 0.06 xExtent: edge yExtent: edge zExtent: edge } Transform { id: cubeScaleTransform matrix: { const offset = cylinderMesh.length + centerSphereMesh.radius const m = Qt.matrix4x4() switch(axis) { case TransformGizmo.Axis.X: { m.translate(Qt.vector3d(offset, 0, 0)) m.rotate(90, Qt.vector3d(0, 0, 1)) break } case TransformGizmo.Axis.Y: { m.translate(Qt.vector3d(0, offset, 0)) break } case TransformGizmo.Axis.Z: { m.translate(Qt.vector3d(0, 0, offset)) m.rotate(90, Qt.vector3d(1, 0, 0)) break } } return m } } } PhongMaterial { id: scaleMaterial ambient: baseColor } TransformGizmoPicker { id: scalePicker mouseController: mouseHandler gizmoMaterial: scaleMaterial gizmoBaseColor: baseColor gizmoAxis: axis gizmoType: TransformGizmo.Type.SCALE onPickedChanged: function(picker) { // Save the current transformations of the OBJECT this.modelMatrix = Transformations3DHelper.modelMatrixToMatrices(objectTransform.matrix) // Compute a scale unit at picking time this.scaleUnit = Transformations3DHelper.computeScaleUnitFromModelMatrix(convertAxisEnum(gizmoAxis), gizmoDisplayTransform.matrix, camera, root.windowSize) // Prevent camera transformations root.pickedChanged(picker.isPressed) } } } // TRANSLATION ENTITY Entity { id: positionEntity components: [coneMesh, coneTransform, positionMaterial, positionPicker, frontLayerComponent] ConeMesh { id: coneMesh bottomRadius: 0.035 topRadius: 0.001 hasBottomEndcap: true hasTopEndcap: true length: 0.13 rings: 2 slices: 8 } Transform { id: coneTransform matrix: { const offset = cylinderMesh.length + centerSphereMesh.radius + 0.4 const m = Qt.matrix4x4() switch (axis) { case TransformGizmo.Axis.X: { m.translate(Qt.vector3d(offset, 0, 0)) m.rotate(-90, Qt.vector3d(0, 0, 1)) break } case TransformGizmo.Axis.Y: { m.translate(Qt.vector3d(0, offset, 0)) break } case TransformGizmo.Axis.Z: { m.translate(Qt.vector3d(0, 0, offset)) m.rotate(90, Qt.vector3d(1, 0, 0)) break } } return m } } PhongMaterial { id: positionMaterial ambient: baseColor } TransformGizmoPicker { id: positionPicker mouseController: mouseHandler gizmoMaterial: positionMaterial gizmoBaseColor: baseColor gizmoAxis: axis gizmoType: TransformGizmo.Type.TRANSLATION onPickedChanged: function(picker) { // Save the current transformations of the OBJECT this.modelMatrix = Transformations3DHelper.modelMatrixToMatrices(objectTransform.matrix) // Compute a scale unit at picking time this.scaleUnit = Transformations3DHelper.computeScaleUnitFromModelMatrix(convertAxisEnum(gizmoAxis), gizmoDisplayTransform.matrix, camera, root.windowSize) // Prevent camera transformations root.pickedChanged(picker.isPressed) } } } // MOVE SURFACE ENTITY INSTANTIATOR => Forward/Backward axis directions // The bounding box has 6 surfaces. Each of the three axes is pointing to a surface. // These three surfaces have "forward direction". The three othersurfaces have "backward direction". NodeInstantiator { model: 2 active: !root.uniformScale // Shouldn't be active for SfmTransform Gizmo node for example Entity { property int direction : { switch (index) { case 0: return TransformGizmo.Direction.Forward case 1: return TransformGizmo.Direction.Backward } } // MOVE SURFACE ENTITY Entity { id: surfaceMoveEntity components: [surfaceMesh, surfaceTransform, surfaceMaterial, frontLayerComponent, surfacePicker] SphereMesh { id: surfaceMesh radius: 0.04 rings: 8 slices: 8 } Transform { id: surfaceTransform matrix: { const m = Qt.matrix4x4() const sign = convertDirectionEnum(direction) const offset = 0.3 switch (axis) { case TransformGizmo.Axis.X: { m.translate(Qt.vector3d(sign * (objectTransform.scale3D.x + offset) / gizmoDisplayTransform.scale3D.x, 0, 0)) m.rotate(-90, Qt.vector3d(0, 0, 1)) break } case TransformGizmo.Axis.Y: { m.translate(Qt.vector3d(0, sign * (objectTransform.scale3D.y + offset) / gizmoDisplayTransform.scale3D.y, 0)) break } case TransformGizmo.Axis.Z: { m.translate(Qt.vector3d(0, 0, sign * (objectTransform.scale3D.z + offset) / gizmoDisplayTransform.scale3D.z)) m.rotate(90, Qt.vector3d(1, 0, 0)) break } } return m } } PhongMaterial { id: surfaceMaterial ambient: baseColor } TransformGizmoPicker { id: surfacePicker mouseController: mouseHandler gizmoMaterial: surfaceMaterial gizmoBaseColor: baseColor gizmoAxis: axis gizmoType: TransformGizmo.Type.ALL property var gizmoDirection: direction onPickedChanged: function (picker) { // Save the current transformations of the OBJECT this.modelMatrix = Transformations3DHelper.modelMatrixToMatrices(objectTransform.matrix) // Compute a scale unit at picking time this.scaleUnit = Transformations3DHelper.computeScaleUnitFromModelMatrix(convertAxisEnum(gizmoAxis), gizmoDisplayTransform.matrix, camera, root.windowSize) // Prevent camera transformations root.pickedChanged(picker.isPressed) } } } } } // ROTATION ENTITY Entity { id: rotationEntity components: [torusMesh, torusTransform, rotationMaterial, rotationPicker, frontLayerComponent] TorusMesh { id: torusMesh radius: cylinderMesh.length + 0.25 minorRadius: axisContainer.lineRadius slices: 8 rings: 32 } Transform { id: torusTransform matrix: { const scaleDiff = 2 * torusMesh.minorRadius + 0.01 // Just to make sure there is no face overlapping const m = Qt.matrix4x4() switch (axis) { case TransformGizmo.Axis.X: m.rotate(90, Qt.vector3d(0, 1, 0)); break case TransformGizmo.Axis.Y: m.rotate(90, Qt.vector3d(1, 0, 0)); m.scale(Qt.vector3d(1 - scaleDiff, 1 - scaleDiff, 1 - scaleDiff)); break case TransformGizmo.Axis.Z: m.scale(Qt.vector3d(1 - 2 * scaleDiff, 1 - 2 * scaleDiff, 1 - 2 * scaleDiff)); break } return m } } PhongMaterial { id: rotationMaterial ambient: baseColor } TransformGizmoPicker { id: rotationPicker mouseController: mouseHandler gizmoMaterial: rotationMaterial gizmoBaseColor: baseColor gizmoAxis: axis gizmoType: TransformGizmo.Type.ROTATION onPickedChanged: function(picker) { // Save the current transformations of the OBJECT this.modelMatrix = Transformations3DHelper.modelMatrixToMatrices(objectTransform.matrix) // No need to compute a scale unit for rotation // Prevent camera transformations root.pickedChanged(picker.isPressed) } } } } } } ================================================ FILE: meshroom/ui/qml/Viewer3D/TransformGizmoPicker.qml ================================================ import Qt3D.Core 2.6 import Qt3D.Render 2.6 import Qt3D.Input 2.6 import Qt3D.Extras 2.15 import Qt3D.Logic 2.6 import QtQuick ObjectPicker { id: root property bool isPressed : false property MouseHandler mouseController property var gizmoMaterial property color gizmoBaseColor property int gizmoAxis property int gizmoType property point screenPoint property var modelMatrix property real scaleUnit property int button signal pickedChanged(var picker) hoverEnabled: true onPressed: function(mouse) { mouseController.enabled = true mouseController.objectPicker = this root.isPressed = true screenPoint = mouse.position button = mouse.button pickedChanged(this) } onEntered: { gizmoMaterial.ambient = "white" } onExited: { if (!isPressed) gizmoMaterial.ambient = gizmoBaseColor } onReleased: { gizmoMaterial.ambient = gizmoBaseColor root.isPressed = false mouseController.objectPicker = null mouseController.enabled = false pickedChanged(this) } } ================================================ FILE: meshroom/ui/qml/Viewer3D/Viewer3D.qml ================================================ import QtQuick import QtQuick.Controls import QtQuick.Layouts import QtQuick.Scene3D 2.6 import Qt3D.Core 2.6 import Qt3D.Render 2.6 import Qt3D.Extras 2.15 import Qt3D.Input 2.6 as Qt3DInput // to avoid clash with Controls2 Action import Controls 1.0 import MaterialIcons 2.2 import Utils 1.0 FocusScope { id: root property int renderMode: 2 readonly property alias library: mediaLibrary readonly property alias mainCamera: mainCamera readonly property vector3d defaultCamPosition: Qt.vector3d(12.0, 10.0, -12.0) readonly property vector3d defaultCamUpVector: Qt.vector3d(-0.358979, 0.861550, 0.358979) // should be accurate, consistent with camera view center readonly property vector3d defaultCamViewCenter: Qt.vector3d(0.0, 0.0, 0.0) readonly property var viewpoint: _currentScene ? _currentScene.selectedViewpoint : null readonly property bool doSyncViewpointCamera: Viewer3DSettings.syncViewpointCamera && (viewpoint && viewpoint.isReconstructed) // Functions function resetCameraPosition() { mainCamera.position = defaultCamPosition mainCamera.upVector = defaultCamUpVector mainCamera.viewCenter = defaultCamViewCenter } function load(filepath, label = undefined) { mediaLibrary.load(filepath, label) } /// View 'attribute' in the 3D Viewer. Media will be loaded if needed. /// Returns whether the attribute can be visualized (matching type and extension). function view(attribute) { if (attribute.desc.type === "File" && Viewer3DSettings.supportedExtensions.indexOf(Filepath.extension(attribute.value)) > - 1) { mediaLibrary.view(attribute) return true } return false } /// Solo (i.e display only) the given attribute. function solo(attribute) { mediaLibrary.solo(mediaLibrary.find(attribute)) } function clear() { mediaLibrary.clear() } SystemPalette { id: activePalette } Scene3D { id: scene3D anchors.fill: parent cameraAspectRatioMode: Scene3D.AutomaticAspectRatio // vs. UserAspectRatio hoverEnabled: true // If true, will trigger positionChanged events in attached MouseHandler aspects: ["logic", "input"] focus: true // We cannot use directly an ExifOrientedViewer since this component is not a Loader // so we redefine the transform using the ExifOrientation utility functions property var orientationTag: (doSyncViewpointCamera && root.viewpoint) ? root.viewpoint.orientation.toString() : "1" transform: [ Rotation { angle: ExifOrientation.rotation(scene3D.orientationTag) origin.x: scene3D.width * 0.5 origin.y: scene3D.height * 0.5 }, Scale { xScale: ExifOrientation.xscale(scene3D.orientationTag) origin.x: scene3D.width * 0.5 origin.y: scene3D.height * 0.5 } ] Keys.onPressed: function(event) { if (event.key === Qt.Key_F) { resetCameraPosition() } else if (Qt.Key_1 <= event.key && event.key < Qt.Key_1 + Viewer3DSettings.renderModes.length) { Viewer3DSettings.renderMode = event.key - Qt.Key_1 } else { event.accepted = false } } Entity { id: rootEntity Camera { id: mainCamera projectionType: CameraLens.PerspectiveProjection enabled: cameraSelector.camera == mainCamera fieldOfView: 45 nearPlane : 0.01 farPlane : 10000.0 position: defaultCamPosition upVector: defaultCamUpVector viewCenter: defaultCamViewCenter aspectRatio: width/height } ViewpointCamera { id: viewpointCamera enabled: cameraSelector.camera === camera viewpoint: root.viewpoint camera.aspectRatio: width/height } Entity { components: [ DirectionalLight{ color: "white" worldDirection: Transformations3DHelper.getRotatedCameraViewVector(cameraSelector.camera.viewVector, cameraSelector.camera.upVector, directionalLightPane.lightPitchValue, directionalLightPane.lightYawValue).normalized() } ] } TrackballGizmo { beamRadius: 4.0/root.height alpha: cameraController.moving ? 1.0 : 0.7 enabled: Viewer3DSettings.displayGizmo && cameraSelector.camera == mainCamera xColor: Colors.red yColor: Colors.green zColor: Colors.blue centerColor: Colors.sysPalette.highlight transform: Transform { translation: mainCamera.viewCenter scale: 0.15 * mainCamera.viewCenter.minus(mainCamera.position).length() } } DefaultCameraController { id: cameraController enabled: cameraSelector.camera == mainCamera windowSize { width: root.width height: root.height } rotationSpeed: 16 trackballSize: 0.9 camera: mainCamera focus: scene3D.activeFocus onMousePressed: function(mouse) { scene3D.forceActiveFocus() } onMouseReleased: function(mouse, moved) { if (moving) return if (!moved && mouse.button === Qt.RightButton) { contextMenu.popup() } } } components: [ RenderSettings { pickingSettings.pickMethod: PickingSettings.PrimitivePicking // Enables point/edge/triangle picking pickingSettings.pickResultMode: PickingSettings.NearestPick renderPolicy: RenderSettings.Always activeFrameGraph: RenderSurfaceSelector { // Use the whole viewport Viewport { normalizedRect: Qt.rect(0.0, 0.0, 1.0, 1.0) CameraSelector { id: cameraSelector camera: doSyncViewpointCamera ? viewpointCamera.camera : mainCamera FrustumCulling { ClearBuffers { clearColor: "transparent" buffers : ClearBuffers.ColorDepthBuffer RenderStateSet { renderStates: [ DepthTest { depthFunction: DepthTest.Less } ] } } LayerFilter { filterMode: LayerFilter.DiscardAnyMatchingLayers layers: Layer {id: drawOnFront} } LayerFilter { filterMode: LayerFilter.AcceptAnyMatchingLayers layers: [drawOnFront] RenderStateSet { renderStates: DepthTest { depthFunction: DepthTest.GreaterOrEqual } } } } } } } }, Qt3DInput.InputSettings { } ] MediaLibrary { id: mediaLibrary renderMode: Viewer3DSettings.renderMode // Picking to set focus point (camera view center) // Only activate it when the 'Control' key is pressed pickingEnabled: cameraController.pickingActive camera: cameraSelector.camera // Used for TransformGizmo in BoundingBox sceneCameraController: cameraController frontLayerComponent: drawOnFront window: root components: [ Transform { id: transform } ] onClicked: function(pick) { if (pick.button === Qt.LeftButton) { mainCamera.viewCenter = pick.worldIntersection } } } Locator3D { enabled: Viewer3DSettings.displayOrigin } Grid3D { enabled: Viewer3DSettings.displayGrid } } } // Image overlay when navigating reconstructed cameras Loader { id: imageOverlayLoader anchors.fill: parent active: doSyncViewpointCamera visible: Viewer3DSettings.showViewpointImageOverlay sourceComponent: ImageOverlay { id: imageOverlay source: root.viewpoint.undistortedImageSource imageRatio: root.viewpoint.orientedImageSize.width * root.viewpoint.pixelAspectRatio / root.viewpoint.orientedImageSize.height uvCenterOffset: root.viewpoint.uvCenterOffset showFrame: Viewer3DSettings.showViewpointImageFrame imageOpacity: Viewer3DSettings.viewpointImageOverlayOpacity } } // Media loading overlay // (Scene3D is frozen while a media is being loaded) Rectangle { anchors.fill: parent visible: mediaLibrary.loading color: Qt.darker(Colors.sysPalette.mid, 1.2) opacity: 0.6 BusyIndicator { anchors.centerIn: parent running: parent.visible } } FloatingPane { visible: Viewer3DSettings.renderMode == 3 anchors.bottom: renderModesPanel.top GridLayout { columns: 2 rowSpacing: 0 RadioButton { text: "SHL File" autoExclusive: true checked: true } TextField { text: Viewer3DSettings.shlFile selectByMouse: true Layout.minimumWidth: 300 onEditingFinished: Viewer3DSettings.shlFile = text } RadioButton { Layout.columnSpan: 2 autoExclusive: true text: "Normals" onCheckedChanged: Viewer3DSettings.displayNormals = checked } } } // Rendering modes FloatingPane { id: renderModesPanel anchors.bottom: parent.bottom padding: 4 Row { Repeater { model: Viewer3DSettings.renderModes delegate: MaterialToolButton { text: modelData["icon"] ToolTip.text: modelData["name"] + " (" + (index+1) + ")" font.pointSize: 11 onClicked: Viewer3DSettings.renderMode = index checked: Viewer3DSettings.renderMode === index checkable: !checked // Hack to disabled check on toggle } } } } // Directional light controller DirectionalLightPane { id: directionalLightPane anchors { bottom: parent.bottom right: parent.right margins: 2 } visible: Viewer3DSettings.displayLightController } // Menu Menu { id: contextMenu MenuItem { text: "Fit All" onTriggered: mainCamera.viewAll() } MenuItem { text: "Reset View" onTriggered: resetCameraPosition() } } } ================================================ FILE: meshroom/ui/qml/Viewer3D/Viewer3DSettings.qml ================================================ pragma Singleton import QtQuick import MaterialIcons 2.2 /** * Viewer3DSettings singleton gathers properties related to the 3D Viewer capabilities, state and display options. */ Item { readonly property Component sfmDataLoaderComp: Qt.createComponent("SfmDataLoader.qml") readonly property bool supportSfmData: sfmDataLoaderComp.status == Component.Ready readonly property Component depthMapLoaderComp: Qt.createComponent("DepthMapLoader.qml") readonly property bool supportDepthMap: depthMapLoaderComp.status == Component.Ready // Supported 3D files extensions readonly property var supportedExtensions: { var exts = [".obj", ".stl", ".fbx", ".gltf", ".ply"]; if (supportSfmData) { exts.push(".abc") exts.push(".json") exts.push(".sfm") } if (supportDepthMap) exts.push(".exr") return exts; } // Available render modes readonly property var renderModes: [ // Can't use ListModel because of MaterialIcons expressions {"name": "Solid", "icon": MaterialIcons.crop_din }, {"name": "Wireframe", "icon": MaterialIcons.details }, {"name": "Textured", "icon": MaterialIcons.texture }, {"name": "Spherical Harmonics", "icon": MaterialIcons.brightness_7} ] // Current render mode property int renderMode: 2 // Spherical Harmonics file property string shlFile: "" // Whether to display normals property bool displayNormals: false // Rasterized point size property real pointSize: 1.5 // Whether point size is fixed or view dependent property bool fixedPointSize: false property real cameraScale: 0.3 // Helpers display property bool displayGrid: true property bool displayGizmo: true property bool displayOrigin: false property bool displayLightController: false // Camera property bool syncViewpointCamera: false property bool syncWithPickedViewId: false // Sync active camera with picked view ID from sequence player if the setting is enabled property bool viewpointImageOverlay: true property real viewpointImageOverlayOpacity: 0.5 readonly property bool showViewpointImageOverlay: syncViewpointCamera && viewpointImageOverlay property bool viewpointImageFrame: false readonly property bool showViewpointImageFrame: syncViewpointCamera && viewpointImageFrame // Cameras' resection IDs property bool displayResectionIds: false property int resectionIdCount: 0 property int resectionId: resectionIdCount property var resectionGroups: [] // Number of cameras for each resection ID } ================================================ FILE: meshroom/ui/qml/Viewer3D/ViewpointCamera.qml ================================================ import QtQuick import Qt3D.Core 2.6 import Qt3D.Render 2.6 /** * ViewpointCamera sets up a Camera to match a Viewpoint's internal parameters. */ Entity { id: root property variant viewpoint property Camera camera: Camera { nearPlane : 0.1 farPlane : 10000.0 viewCenter: Qt.vector3d(0.0, 0.0, -1.0) } components: [ Transform { id: transform } ] StateGroup { states: [ State { name: "valid" when: root.viewpoint !== null PropertyChanges { target: camera fieldOfView: root.viewpoint.fieldOfView upVector: root.viewpoint.upVector } PropertyChanges { target: transform rotation: root.viewpoint.rotation translation: root.viewpoint.translation } } ] } } ================================================ FILE: meshroom/ui/qml/Viewer3D/qmldir ================================================ module Viewer3D Viewer3D 1.0 Viewer3D.qml singleton Viewer3DSettings 1.0 Viewer3DSettings.qml DefaultCameraController 1.0 DefaultCameraController.qml Locator3D 1.0 Locator3D.qml Grid3D 1.0 Grid3D.qml Inspector3D 1.0 Inspector3D.qml ================================================ FILE: meshroom/ui/qml/WorkspaceView.qml ================================================ import QtQuick import QtQuick.Controls import QtQuick.Layouts import Controls 1.0 import MaterialIcons 2.2 import ImageGallery 1.0 import Viewer 1.0 import Viewer3D 1.0 /** * WorkspaceView is an aggregation of Meshroom's main modules. * * It contains an ImageGallery, a 2D and a 3D viewer to manipulate and visualize scene data. */ Item { id: root property variant currentScene: _currentScene readonly property variant cameraInits: _currentScene ? _currentScene.cameraInits : null property bool readOnly: false property alias panel3dViewer: panel3dViewerLoader.item readonly property Viewer2D viewer2D: viewer2D readonly property alias imageGallery: imageGallery readonly property TextViewer viewerText: textViewer property alias mediaViewerTabIndex: mediaViewerPanel.currentTab // Text Viewer occupies index 1 when Image Viewer is also visible, else index 0 readonly property int _textViewerTabIndex: settingsUILayout.showImageViewer ? 1 : 0 // Use settings instead of visible property as property changes are not propagated visible: settingsUILayout.showImageGallery || settingsUILayout.showImageViewer || settingsUILayout.showViewer3D || settingsUILayout.showTextViewer // Load a 3D media file in the 3D viewer function load3DMedia(filepath, label = undefined) { if (panel3dViewerLoader.active) { panel3dViewerLoader.item.viewer3D.load(filepath, label) } } Connections { target: currentScene function onGraphChanged() { if (panel3dViewerLoader.active) { panel3dViewerLoader.item.viewer3D.clear() } } function onSfmChanged() { viewSfM() } function onSfmReportChanged() { viewSfM() } } Component.onCompleted: viewSfM() // Load the current scene's SfM file function viewSfM() { var activeNode = _currentScene.activeNodes ? _currentScene.activeNodes.get('sfm').node : null if (!activeNode) return if (panel3dViewerLoader.active) { panel3dViewerLoader.item.viewer3D.view(activeNode.attribute('output')) } } SystemPalette { id: activePalette } MSplitView { id: mainSplitView anchors.fill: parent orientation: Qt.Horizontal MSplitView { id: leftSplitView visible: settingsUILayout.showImageGallery orientation: Qt.Vertical SplitView.preferredWidth: imageGallery.defaultCellSize * 2 + 20 SplitView.minimumWidth: imageGallery.defaultCellSize ImageGallery { id: imageGallery visible: settingsUILayout.showImageGallery SplitView.fillHeight: true readOnly: root.readOnly cameraInits: root.cameraInits cameraInit: currentScene ? currentScene.cameraInit : null tempCameraInit: currentScene ? currentScene.tempCameraInit : null cameraInitIndex: currentScene ? currentScene.cameraInitIndex : -1 onRemoveImageRequest: function(attribute) { currentScene.removeImage(attribute) } onAllViewpointsCleared: currentScene.selectedViewId = "-1" onFilesDropped: function(drop) { if (drop["meshroomScenes"].length == 1) { ensureSaved(function() { if (currentScene.handleFilesUrl(drop, cameraInit)) { MeshroomApp.addRecentProjectFile(drop["meshroomScenes"][0]) } }) } else { currentScene.handleFilesUrl(drop, cameraInit) } } } } TabPanel { id: mediaViewerPanel visible: settingsUILayout.showImageViewer || settingsUILayout.showTextViewer implicitWidth: Math.round(parent.width * 0.35) SplitView.fillWidth: true SplitView.minimumWidth: 50 tabs: { var t = [] if (settingsUILayout.showImageViewer) t.push("Image Viewer") if (settingsUILayout.showTextViewer) t.push("Text Viewer") return t } headerBar: RowLayout { spacing: 4 // Loading indicator for image viewer BusyIndicator { id: mediaViewerLoadingIndicator padding: 0 implicitWidth: 12 implicitHeight: 12 running: settingsUILayout.showImageViewer && mediaViewerPanel.currentTab === 0 && viewer2D.loadingModules.length > 0 visible: running } Label { visible: mediaViewerLoadingIndicator.visible text: "Loading " + viewer2D.loadingModules font.italic: true } MaterialToolButton { text: MaterialIcons.more_vert font.pointSize: 11 padding: 2 checkable: true checked: imageViewerMenu.visible visible: settingsUILayout.showImageViewer && mediaViewerPanel.currentTab === 0 onClicked: imageViewerMenu.open() Menu { id: imageViewerMenu y: parent.height x: -width + parent.width Action { id: displayImageToolBarAction text: "Display HDR Toolbar" checkable: true checked: true enabled: viewer2D.useFloatImageViewer } Action { id: displayLensDistortionToolBarAction text: "Display Lens Distortion Toolbar" checkable: true checked: true enabled: viewer2D.useLensDistortionViewer } Action { id: displayPanoramaToolBarAction text: "Display Panorama Toolbar" checkable: true checked: true enabled: viewer2D.usePanoramaViewer } Action { id: displayImagePathAction text: "Display Image Path" checkable: true checked: true && !viewer2D.usePanoramaViewer } Action { id: enable8bitViewerAction text: "Enable 8-bit Viewer" checkable: true checked: MeshroomApp.default8bitViewerEnabled } Action { id: enableSequencePlayerAction text: "Enable Sequence Player" checkable: true checked: MeshroomApp.defaultSequencePlayerEnabled } } } } Viewer2D { id: viewer2D anchors.fill: parent visible: settingsUILayout.showImageViewer && mediaViewerPanel.currentTab === 0 viewIn3D: root.load3DMedia DropArea { anchors.fill: parent keys: ["text/uri-list"] onDropped: function(drop) { viewer2D.loadExternal(drop.urls[0]); } } Rectangle { z: -1 anchors.fill: parent color: Qt.darker(activePalette.base, 1.1) } } TextViewer { id: textViewer anchors.fill: parent visible: settingsUILayout.showTextViewer && mediaViewerPanel.currentTab === root._textViewerTabIndex DropArea { anchors.fill: parent keys: ["text/uri-list"] onDropped: function(drop) { textViewer.source = drop.urls[0] } } } } Item { id: viewer3DContainer visible: settingsUILayout.showViewer3D Layout.minimumWidth: 20 Layout.minimumHeight: 80 Layout.fillHeight: true implicitWidth: Math.round(parent.width * 0.45) Loader { id: panel3dViewerLoader active: settingsUILayout.showViewer3D visible: active anchors.fill: parent sourceComponent: panel3dViewerComponent } } Component { id: panel3dViewerComponent Panel { id: panel3dViewer title: "3D Viewer" property alias viewer3D: c_viewer3D MSplitView { id: c_viewer3DSplitView anchors.fill: parent orientation: Qt.Horizontal Viewer3D { id: c_viewer3D SplitView.fillWidth: true SplitView.minimumWidth: 50 DropArea { anchors.fill: parent keys: ["text/uri-list"] onDropped: function(drop) { drop.urls.forEach(function(url) { load3DMedia(url) }) } } Connections { target: viewer2D function onSync3DSelectedChanged() { Viewer3DSettings.syncWithPickedViewId = viewer2D.sync3DSelected } } } // Inspector Panel Inspector3D { id: inspector3d SplitView.preferredWidth: 220 SplitView.minimumWidth: 100 mediaLibrary: c_viewer3D.library camera: c_viewer3D.mainCamera uigraph: currentScene onNodeActivated: _currentScene.setActiveNode(node) } } } } } } ================================================ FILE: meshroom/ui/qml/main.qml ================================================ import QtCore import QtQuick import QtQuick.Controls import QtQuick.Dialogs import Qt.labs.platform as Platform ApplicationWindow { id: _window width: settingsGeneral.windowWidth height: settingsGeneral.windowHeight minimumWidth: 650 minimumHeight: 500 visible: true property bool isClosing: false title: { var t = (_currentScene && _currentScene.graph && _currentScene.graph.filepath) ? _currentScene.graph.filepath : "Untitled" if (_currentScene && !_currentScene.undoStack.clean) t += "*" t += " - " + Qt.application.name + " " + Qt.application.version return t } onClosing: function(close) { // Make sure document is saved before exiting application close.accepted = false if (!ensureNotComputing()) return isClosing = true ensureSaved(function() { Qt.quit() }) } // QPalette is not convertible to QML palette (anymore) Component.onCompleted: { palette.alternateBase = _PaletteManager.alternateBase palette.base = _PaletteManager.base palette.button = _PaletteManager.button palette.buttonText = _PaletteManager.buttonText palette.highlight = _PaletteManager.highlight palette.highlightedText = _PaletteManager.highlightedText palette.link = _PaletteManager.link palette.mid = _PaletteManager.mid palette.shadow = _PaletteManager.shadow palette.text = _PaletteManager.text palette.toolTipBase = _PaletteManager.toolTipBase palette.toolTipText = _PaletteManager.toolTipText palette.window = _PaletteManager.window palette.windowText = _PaletteManager.windowText palette.disabled.buttonText = _PaletteManager.disabledButtonText palette.disabled.highlight = _PaletteManager.disabledHighlight palette.disabled.highlightedText = _PaletteManager.disabledHighlightedText palette.disabled.text = _PaletteManager.disabledText palette.disabled.windowText = _PaletteManager.disabledWindowText } SystemPalette { id: activePalette } SystemPalette { id: disabledPalette; colorGroup: SystemPalette.Disabled } Settings { id: settingsGeneral category: "General" property int windowWidth: 1280 property int windowHeight: 720 } Component.onDestruction: { // Store main window dimensions in persisting Settings settingsGeneral.windowWidth = _window.width settingsGeneral.windowHeight = _window.height } function initFileDialogFolder(dialog, importImages = false) { let folder = "" let project = "" try { // The list of recent projects might be empty, hence the try/catch project = MeshroomApp.recentProjectFiles[0]["path"] } catch (error) { console.info("The list of recent projects is currently empty.") } let currentItem = mainStack.currentItem if (currentItem instanceof Homepage) { // From the homepage, take the folder from the most recent project (no prior check on its existence) if (project != "" && Filepath.exists(project)) { folder = Filepath.stringToUrl(Filepath.dirname(project)) } } else { if (currentItem.imagesFolder.toString() === "" && currentItem.workspaceView.imageGallery.galleryGrid.itemAtIndex(0) !== null) { // Set the initial folder for the "import images" dialog if it has not been set already currentItem.imagesFolder = Filepath.stringToUrl(Filepath.dirname(currentItem.workspaceView.imageGallery.galleryGrid.itemAtIndex(0).source)) } if (_currentScene.graph && _currentScene.graph.filepath) { // If the opened project has been saved, the dialog will open in the same folder folder = Filepath.stringToUrl(Filepath.dirname(_currentScene.graph.filepath)) } else { // If the currently opened project has not been saved, the dialog will open in the same // folder as the most recent project if it exists; otherwise, it will not be set if (project != "" && Filepath.exists(project)) { folder = Filepath.stringToUrl(Filepath.dirname(project)) } } // If the dialog that is being opened is the "import images" dialog, use the "imagesFolder" property // which contains the last folder used to import images rather than the folder in which // projects have been saved const imageFolderPath = currentItem.imagesFolder.toString() if (importImages && imageFolderPath !== "" && Filepath.exists(imageFolderPath)) { folder = Filepath.stringToUrl(imageFolderPath) } } dialog.folder = folder } Platform.FileDialog { id: openFileDialog title: "Open File" nameFilters: ["Meshroom Graphs (*.mg)"] onAccepted: { if (mainStack.currentItem instanceof Homepage) { mainStack.push("Application.qml") } if (_currentScene.load(currentFile)) { MeshroomApp.addRecentProjectFile(currentFile.toString()) } } } // Check if document has been saved function ensureSaved(callback) { var saved = _currentScene.undoStack.clean if (!saved) { // If current document is modified, open "unsaved dialog" mainStack.currentItem.unsavedDialog.prompt(callback) } else { // Otherwise, directly call the callback callback() } return saved } // Check and return whether no local computation is in progress function ensureNotComputing() { if (_currentScene.computingLocally) { // Open a warning dialog to ask for computation to be stopped mainStack.currentItem.computingAtExitDialog.open() return false } return true } Action { shortcut: "Ctrl+Shift+P" onTriggered: _PaletteManager.togglePalette() } StackView { id: mainStack anchors.fill: parent Component.onCompleted: { if (_currentScene.active) { mainStack.push("Application.qml") } else { mainStack.push("Homepage.qml") } } pushExit: Transition {} pushEnter: Transition {} popExit: Transition {} popEnter: Transition {} replaceEnter: Transition {} replaceExit: Transition {} } background: MouseArea { onPressed: { forceActiveFocus(); } } } ================================================ FILE: meshroom/ui/scene.py ================================================ import json import logging import math import os from collections.abc import Iterable from multiprocessing.pool import ThreadPool from threading import Thread from typing import Callable from PySide6.QtCore import QObject, Slot, Property, Signal, QUrl, QSizeF, QPoint from PySide6.QtGui import QMatrix4x4, QMatrix3x3, QQuaternion, QVector3D, QVector2D import meshroom.core import meshroom.common from meshroom import multiview from meshroom.common.qt import QObjectListModel from meshroom.core import Version from meshroom.core.node import Node, CompatibilityNode, Status, Position, CompatibilityIssue from meshroom.core.taskManager import TaskManager from meshroom.core.evaluation import MathEvaluator from meshroom.core.plugins import NodePluginStatus from meshroom.ui import commands from meshroom.ui.graph import UIGraph from meshroom.ui.utils import makeProperty from meshroom.ui.components.filepath import FilepathHelper class Message(QObject): """ Simple structure wrapping a high-level message. """ def __init__(self, title, text, detailedText="", parent=None): super().__init__(parent) self._title = title self._text = text self._detailedText = detailedText title = Property(str, lambda self: self._title, constant=True) text = Property(str, lambda self: self._text, constant=True) detailedText = Property(str, lambda self: self._detailedText, constant=True) class ViewpointWrapper(QObject): """ ViewpointWrapper is a high-level object that wraps an input image in the context of a Scene. It exposes the attributes of the image and its corresponding camera when reconstructed. """ initialParamsChanged = Signal() sfmParamsChanged = Signal() undistortedImageParamsChanged = Signal() internalChanged = Signal() principalPointCorrectedChanged = Signal() uvCenterOffsetChanged = Signal() def __init__(self, viewpointAttribute, scene): """ Viewpoint constructor Args: viewpointAttribute (GroupAttribute): viewpoint attribute scene (Scene): owner scene of this Viewpoint """ super().__init__(parent=scene) self._viewpoint = viewpointAttribute self._scene = scene # CameraInit self._initialIntrinsics = None # StructureFromMotion self._T = None # translation self._R = None # rotation self._solvedIntrinsics = {} self._reconstructed = False # PrepareDenseScene self._undistortedImagePath = '' self._activeNode_PrepareDenseScene = self._scene.activeNodes.get("PrepareDenseScene") self._activeNode_ExportAnimatedCamera = self._scene.activeNodes.get("ExportAnimatedCamera") self._activeNode_ExportImages = self._scene.activeNodes.get("ExportImages") self._principalPointCorrected = False self.principalPointCorrectedChanged.connect(self.uvCenterOffsetChanged) self.sfmParamsChanged.connect(self.uvCenterOffsetChanged) # update internally cached variables self._updateInitialParams() self._updateSfMParams() self._updateUndistortedImageParams() # trigger internal members updates when scene members changes self._scene.cameraInitChanged.connect(self._updateInitialParams) self._scene.sfmReportChanged.connect(self._updateSfMParams) if self._activeNode_PrepareDenseScene: self._activeNode_PrepareDenseScene.nodeChanged.connect(self._updateUndistortedImageParams) if self._activeNode_ExportAnimatedCamera: self._activeNode_ExportAnimatedCamera.nodeChanged.connect(self._updateUndistortedImageParams) if self._activeNode_ExportImages: self._activeNode_ExportImages.nodeChanged.connect(self._updateUndistortedImageParams) def _updateInitialParams(self): """ Update internal members depending on CameraInit. """ if not self._scene.cameraInit: self._initialIntrinsics = None self._metadata = {} else: self._initialIntrinsics = self._scene.getIntrinsic(self._viewpoint) try: # When the viewpoint attribute has already been deleted, metadata.value becomes a PySide property (whereas a string is expected) self._metadata = json.loads(self._viewpoint.metadata.value) if isinstance(self._viewpoint.metadata.value, str) and self._viewpoint.metadata.value else None except Exception as exc: logging.warning(f"Failed to parse Viewpoint metadata: '{exc}', '{str(self._viewpoint.metadata.value)}'") self._metadata = {} if not self._metadata: self._metadata = {} self.initialParamsChanged.emit() def _updateSfMParams(self): """ Update internal members depending on StructureFromMotion. """ if not self._scene.sfm: self._T = None self._R = None self._solvedIntrinsics = {} self._reconstructed = False else: self._solvedIntrinsics = self._scene.getSolvedIntrinsics(self._viewpoint) self._R, self._T = self._scene.getPoseRT(self._viewpoint) self._reconstructed = self._R is not None self.sfmParamsChanged.emit() def _updateUndistortedImageParams(self): """ Update internal members depending on PrepareDenseScene or ExportAnimatedCamera. """ # undistorted image path try: if self._activeNode_ExportAnimatedCamera and self._activeNode_ExportAnimatedCamera.node: self._undistortedImagePath = FilepathHelper.resolve(FilepathHelper, self._activeNode_ExportAnimatedCamera.node.outputImages.value, self._viewpoint) self._principalPointCorrected = self._activeNode_ExportAnimatedCamera.node.correctPrincipalPoint.value elif self._activeNode_PrepareDenseScene and self._activeNode_PrepareDenseScene.node: self._undistortedImagePath = FilepathHelper.resolve(FilepathHelper, self._activeNode_PrepareDenseScene.node.undistorted.value, self._viewpoint) self._principalPointCorrected = False elif self._activeNode_ExportImages and self._activeNode_ExportImages.node: self._undistortedImagePath = FilepathHelper.resolve(FilepathHelper, self._activeNode_ExportImages.node.undistorted.value, self._viewpoint) self._principalPointCorrected = False else: self._undistortedImagePath = '' self._principalPointCorrected = False except Exception: self._undistortedImagePath = '' self._principalPointCorrected = False logging.warning("Failed to retrieve undistorted images path.") self.undistortedImageParamsChanged.emit() self.principalPointCorrectedChanged.emit() # Get the underlying Viewpoint attribute wrapped by this Viewpoint. attribute = Property(QObject, lambda self: self._viewpoint, constant=True) @Property(type="QVariant", notify=initialParamsChanged) def initialIntrinsics(self): """ Get viewpoint's initial intrinsics. """ return self._initialIntrinsics @Property(type="QVariant", notify=initialParamsChanged) def metadata(self): """ Get image metadata. """ return self._metadata @Property(type=QSizeF, notify=initialParamsChanged) def imageSize(self): """ Get image size (width as the largest dimension). """ if not self._initialIntrinsics: return QSizeF(0, 0) return QSizeF(self._initialIntrinsics.width.value, self._initialIntrinsics.height.value) @Property(type=int, notify=initialParamsChanged) def orientation(self): """ Get image orientation based on its metadata. """ return int(self.metadata.get("Orientation", 1)) @Property(type=QSizeF, notify=initialParamsChanged) def orientedImageSize(self): """ Get image size taking into account its orientation. """ if self.orientation in (5, 6, 7, 8): return QSizeF(self.imageSize.height(), self.imageSize.width()) else: return self.imageSize @Property(type=bool, notify=sfmParamsChanged) def isReconstructed(self): """ Return whether this viewpoint corresponds to a reconstructed camera. """ return self._reconstructed @Property(type="QVariant", notify=sfmParamsChanged) def solvedIntrinsics(self): return self._solvedIntrinsics @Property(type=QVector3D, notify=sfmParamsChanged) def translation(self): """ Get the camera translation as a 3D vector. """ if self._T is None: return None return QVector3D(self._T[0], -self._T[1], -self._T[2]) @Property(type=QQuaternion, notify=sfmParamsChanged) def rotation(self): """ Get the camera rotation as a quaternion. """ if self._R is None: return None rot = QMatrix3x3([ self._R[0], -self._R[1], -self._R[2], -self._R[3], self._R[4], self._R[5], -self._R[6], self._R[7], self._R[8]] ) return QQuaternion.fromRotationMatrix(rot) @Property(type=QMatrix4x4, notify=sfmParamsChanged) def pose(self): """ Get the camera pose of 'viewpoint' as a 4x4 matrix. """ if self._R is None or self._T is None: return None # convert transform matrix for Qt return QMatrix4x4( self._R[0], -self._R[1], -self._R[2], self._T[0], -self._R[3], self._R[4], self._R[5], -self._T[1], -self._R[6], self._R[7], self._R[8], -self._T[2], 0, 0, 0, 1 ) @Property(type=QVector3D, notify=sfmParamsChanged) def upVector(self): """ Get camera up vector. """ return QVector3D(0.0, 1.0, 0.0) @Property(type=QVector2D, notify=uvCenterOffsetChanged) def uvCenterOffset(self): """ Get UV offset corresponding to the camera principal point. """ if not self.solvedIntrinsics or self._principalPointCorrected: return None pp = self.solvedIntrinsics["principalPoint"] # compute principal point offset in UV space offset = QVector2D(float(pp[0]) / self.imageSize.width(), float(pp[1]) / self.imageSize.height()) return offset @Property(type=float, notify=sfmParamsChanged) def fieldOfView(self): """ Get camera vertical field of view in degrees. """ if not self.solvedIntrinsics: return None focalLength = self.solvedIntrinsics["focalLength"] #We assume that if the width is less than the weight #It's because the image has been rotated and not #because the sensor has some unusual shape sensorWidth = self.solvedIntrinsics["sensorWidth"] sensorHeight = self.solvedIntrinsics["sensorHeight"] if self.imageSize.height() > self.imageSize.width(): sensorWidth, sensorHeight = sensorHeight, sensorWidth if self.orientation in (5, 6, 7, 8): return 2.0 * math.atan(float(sensorWidth) / (2.0 * float(focalLength))) * 180.0 / math.pi else: return 2.0 * math.atan(float(sensorHeight) / (2.0 * float(focalLength))) * 180.0 / math.pi @Property(type=float, notify=sfmParamsChanged) def pixelAspectRatio(self): """ Get camera pixel aspect ratio. """ if not self.solvedIntrinsics: return 1.0 return float(self.solvedIntrinsics["pixelRatio"]) @Property(type=QUrl, notify=undistortedImageParamsChanged) def undistortedImageSource(self): """ Get path to undistorted image source if available. """ return QUrl.fromLocalFile(self._undistortedImagePath) def parseSfMJsonFile(sfmJsonFile): """ Parse the SfM Json file and return views, poses and intrinsics as three dicts with viewId, poseId and intrinsicId as keys. """ if not os.path.exists(sfmJsonFile): return {}, {}, {} with open(sfmJsonFile) as jsonFile: report = json.load(jsonFile) views = dict() poses = dict() intrinsics = dict() for view in report['views']: views[view['viewId']] = view if "poses" in report: for pose in report['poses']: poses[pose['poseId']] = pose['pose'] for intrinsic in report['intrinsics']: intrinsics[intrinsic['intrinsicId']] = intrinsic return views, poses, intrinsics class ActiveNode(QObject): """ Hold one active node for a given NodeType. """ def __init__(self, nodeType, parent=None): super().__init__(parent) self.nodeType = nodeType self._node = None nodeChanged = Signal() node = makeProperty(QObject, "_node", nodeChanged, resetOnDestroy=True) class Scene(UIGraph): """ Specialization of a UIGraph designed to manage a Meshroom scene """ activeNodeCategories = { # All nodes generating a sfm scene (3D reconstruction or panorama) "sfm": ["StructureFromMotion", "GlobalSfM", "PanoramaEstimation", "SfMTransform", "SfMAlignment", "SfMExpanding", "SfMBootstraping"], # All nodes generating a sfmData file "sfmData": ["CameraInit", "DistortionCalibration", "StructureFromMotion", "GlobalSfM", "PanoramaEstimation", "SfMTransfer", "SfMTransform", "SfMAlignment", "ApplyCalibration", "SfMExpanding", "SfMBootstraping"], # All nodes generating depth map files "allDepthMap": ["DepthMap", "DepthMapFilter"], # Nodes that can be used to provide features folders to the UI "featureProvider": ["FeatureExtraction", "FeatureMatching", "StructureFromMotion", "RomaReducer"], # Nodes that can be used to provide matches folders to the UI "matchProvider": ["FeatureMatching", "StructureFromMotion", "RomaReducer"], # Nodes that can be used to provide tracks files to the UI "trackProvider": ["TracksBuilding", "SfMBootstraping", "SfMExpanding"] } # Nodes accessed from the UI uiNodes = [ "LdrToHdrMerge", "LdrToHdrCalibration", "ImageProcessing", "PhotometricStereo", "PanoramaInit", "ColorCheckerDetection", "SphereDetection", ] def __init__(self, undoStack: commands.UndoStack, taskManager: TaskManager, defaultPipeline: str="", parent: QObject=None): super().__init__(undoStack, taskManager, parent) # initialize member variables for key steps of the 3D reconstruction pipeline self._active = False self._activeNodes = meshroom.common.DictModel(keyAttrName="nodeType") self.initActiveNodes() # initialize activeAttributes (attributes currently visible in some viewers) self._displayedAttr2D = None self._displayedAttrs3D = meshroom.common.ListModel() # - CameraInit self._cameraInit = None # current CameraInit node self._cameraInits = QObjectListModel(parent=self) # all CameraInit nodes self._buildingIntrinsics = False self.intrinsicsBuilt.connect(self.onIntrinsicsAvailable) self.cameraInitChanged.connect(self.onCameraInitChanged) self._tempCameraInit = None self.importImagesFailed.connect(self.onImportImagesFailed) # - SfM self._sfm = None self._views = None self._poses = None self._solvedIntrinsics = None self._selectedViewId = None self._selectedViewpoint = None self._pickedViewId = None self._currentViewPath = "" self._workerThreads = ThreadPool(processes=1) # react to internal graph changes to update those variables self.graphChanged.connect(self.onGraphChanged) # Connect the pluginsReloaded signal to the onPluginsReloaded function self.pluginsReloaded.connect(self._onPluginsReloaded) self.setDefaultPipeline(defaultPipeline) def __del__(self): self._workerThreads.terminate() self._workerThreads.join() def setActive(self, active): self._active = active @Slot() def clear(self): self.clearActiveNodes() super().clear() self.setActive(False) def setDefaultPipeline(self, defaultPipeline): self._defaultPipeline = defaultPipeline def setSubmitLabel(self, submitLabel): self.submitLabel = submitLabel def initActiveNodes(self): # Create all possible entries for category, _ in self.activeNodeCategories.items(): self._activeNodes.add(ActiveNode(category, parent=self)) # For all nodes declared to be accessed by the UI usedNodeTypes = {j for i in self.activeNodeCategories.values() for j in i} allLoadedNodeTypes = set(meshroom.core.pluginManager.getRegisteredNodePlugins().keys()) allUiNodes = set(self.uiNodes) | usedNodeTypes | allLoadedNodeTypes for nodeType in allUiNodes: self._activeNodes.add(ActiveNode(nodeType, parent=self)) def clearActiveNodes(self): for key in self._activeNodes.keys(): self._activeNodes.get(key).node = None def onCameraInitChanged(self): if self._cameraInit is None: return # Update active nodes when CameraInit changes nodes = self._graph.dfsOnDiscover(startNodes=[self._cameraInit], reverse=True)[0] self.setActiveNodes(nodes) @Slot() def reloadPlugins(self): """ Launch _reloadPlugins in a worker thread to avoid blocking the ui. """ self._workerThreads.apply_async(func=self._reloadPlugins, args=()) def _reloadPlugins(self): """ Reload all the NodePlugins from all the registered plugins. The nodes in the graph will be updated to match the changes in the description, if there was any. """ reloadedNodes: list[str] = [] errorNodes: list[str] = [] for plugin in meshroom.core.pluginManager.getPlugins().values(): for node in plugin.nodes.values(): if node.reload(): reloadedNodes.append(node.nodeDescriptor.__name__) else: if node.status == NodePluginStatus.DESC_ERROR or node.status == NodePluginStatus.ERROR: errorNodes.append(node.nodeDescriptor.__name__) self.pluginsReloaded.emit(reloadedNodes, errorNodes) @Slot(list) def _onPluginsReloaded(self, reloadedNodes: list, errorNodes: list): self._graph.reloadNodePlugins(reloadedNodes) if len(errorNodes) > 0: self.parent().showMessage(f"Some plugins failed to reload: {', '.join(errorNodes)}", "error") else: self.parent().showMessage("Plugins reloaded!", "ok") @Slot(result=bool) @Slot(str, result=bool) def new(self, pipeline=None): """ Create a new pipeline. """ p = pipeline if pipeline is not None else self._defaultPipeline # Lower the input and the dictionary keys to make sure that all input types can be found: # - correct pipeline name but the case does not match (e.g. panoramaHDR instead of panoramaHdr) # - lowercase pipeline name given through the "New Pipeline" menu loweredPipelineTemplates = {k.lower(): v for k, v in meshroom.core.pipelineTemplates.items()} filepath = loweredPipelineTemplates.get(p.lower(), p) return self._loadWithErrorReport(self.initFromTemplate, filepath) def _initFromTemplateWithCopyOutputs(self, filepath): self.initFromTemplate(filepath, copyOutputs=True) @Slot(result=bool) @Slot(str, result=bool) def newWithCopyOutputs(self, pipeline=None): """ Create a new pipeline with all the "CopyFiles" nodes included if the provided template has any. """ p = pipeline if pipeline is not None else self._defaultPipeline loweredPipelineTemplates = {k.lower(): v for k, v in meshroom.core.pipelineTemplates.items()} filepath = loweredPipelineTemplates.get(p.lower(), p) return self._loadWithErrorReport(self._initFromTemplateWithCopyOutputs, filepath) @Slot(str, result=bool) @Slot(QUrl, result=bool) def load(self, url): 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) def _loadWithErrorReport(self, loadFunction: Callable[[str], None], filepath: str): logging.info(f"Load project file: '{filepath}'") try: loadFunction(filepath) # warn about pre-release projects being automatically upgraded if Version(self._graph.fileReleaseVersion).major == "0": self.warning.emit(Message( "Automatic project upgrade", "This project was created with an older version of Meshroom and has been automatically upgraded.\n" "Data might have been lost in the process.", "Open it with the corresponding version of Meshroom to recover your data." )) self.setActive(True) return True except FileNotFoundError: self.error.emit( Message( "No Such File", f"Error While Loading '{os.path.basename(filepath)}': No Such File.", "" ) ) logging.error(f"Error while loading '{filepath}': No Such File.") except Exception: import traceback trace = traceback.format_exc() self.error.emit( Message( "Error While Loading Project File", f"An unexpected error has occurred while loading file: '{os.path.basename(filepath)}'", trace ) ) logging.error(f"Error while loading '{filepath}'.") logging.error(trace) return False def onGraphChanged(self): """ React to the change of the internal graph. """ self.selectedViewId = "-1" self.tempCameraInit = None self.updateCameraInits() self.resetActiveNodePerCategory() self.sfm = self.lastSfmNode() if not self._graph: return # TODO: listen specifically for cameraInit creation/deletion self._graph.nodes.countChanged.connect(self.updateCameraInits) @Slot(QObject) def getViewpoints(self): """ Return the Viewpoints model. """ # TODO: handle multiple Viewpoints models if self.tempCameraInit: return self.tempCameraInit.viewpoints.value elif self._cameraInit: return self._cameraInit.viewpoints.value else: return QObjectListModel(parent=self) def updateCameraInits(self): cameraInits = self._graph.nodesOfType("CameraInit", sortedByIndex=True) if set(self._cameraInits.objectList()) == set(cameraInits): return self._cameraInits.setObjectList(cameraInits) if self.cameraInit is None or self.cameraInit not in cameraInits: self.cameraInit = cameraInits[0] if cameraInits else None # Manually emit the signal to ensure the active CameraInit index is always up-to-date in the UI self.cameraInitChanged.emit() def getCameraInitIndex(self): if not self._cameraInit: # No CameraInit node return -1 if not self._cameraInit.graph: # The CameraInit node is a temporary one not attached to a graph return -1 return self._cameraInits.indexOf(self._cameraInit) def setCameraInitIndex(self, idx): camInit = self._cameraInits[idx] if self._cameraInits else None self.cameraInit = camInit # Update the active viewpoint accordingly if self.viewpoints: self.setSelectedViewId(self.viewpoints[0].viewId.value) def setCameraInitNode(self, node): if self._cameraInit == node: return self.setCameraInitIndex(self._cameraInits.indexOf(node)) @Slot() def clearTempCameraInit(self): self.tempCameraInit = None @Slot(QObject, str) def setupTempCameraInit(self, node, attrName): if not node or not attrName: self.tempCameraInit = None return sfmFile = node.attribute(attrName).value if not sfmFile or not os.path.isfile(sfmFile): self.tempCameraInit = None return nodeDesc = meshroom.core.pluginManager.getRegisteredNodePlugin("CameraInit").nodeDescriptor() views, intrinsics = nodeDesc.readSfMData(sfmFile) tmpCameraInit = Node("CameraInit", viewpoints=views, intrinsics=intrinsics) tmpCameraInit.locked = True self.tempCameraInit = tmpCameraInit rootNode = self.graph.dfsOnFinish([node])[0][0] if rootNode.nodeType == "CameraInit": self.setCameraInitNode(rootNode) @Slot(QObject, result=QVector3D) def getAutoFisheyeCircle(self, panoramaInit): if not panoramaInit or not panoramaInit.isComputed: return QVector3D(0.0, 0.0, 0.0) if not panoramaInit.attribute("estimateFisheyeCircle").value: return QVector3D(0.0, 0.0, 0.0) sfmFile = panoramaInit.attribute('outSfMData').value if not os.path.exists(sfmFile): return QVector3D(0.0, 0.0, 0.0) # skip decoding errors to avoid potential exceptions due to non utf-8 characters in images metadata with open(sfmFile, encoding='utf-8', errors='ignore') as f: data = json.load(f) intrinsics = data.get('intrinsics', []) if len(intrinsics) == 0: return QVector3D(0.0, 0.0, 0.0) intrinsic = intrinsics[0] res = QVector3D(float(intrinsic.get("fisheyeCircleCenterX", 0.0)) - float(intrinsic.get("width", 0.0)) * 0.5, float(intrinsic.get("fisheyeCircleCenterY", 0.0)) - float(intrinsic.get("height", 0.0)) * 0.5, float(intrinsic.get("fisheyeCircleRadius", 0.0))) return res def lastSfmNode(self): """ Retrieve the last SfM node from the initial CameraInit node. """ return self.lastNodeOfType(self.activeNodeCategories['sfm'], self._cameraInit, Status.SUCCESS) def lastNodeOfType(self, nodeTypes, startNode, preferredStatus=None): """ Returns the last node of the given type starting from 'startNode'. If 'preferredStatus' is specified, the last node with this status will be considered in priority. Args: nodeTypes (str list): the node types startNode (Node): the node to start from preferredStatus (Status): (optional) the node status to prioritize Returns: Node: the node matching the input parameters or None """ if not startNode: return None nodes = self._graph.dfsOnDiscover(startNodes=[startNode], filterTypes=nodeTypes, reverse=True)[0] if not nodes: return None # order the nodes according to their depth in the graph, then according to their name nodes.sort(key=lambda n: (n.depth, n.name)) node = nodes[-1] if preferredStatus: node = next((n for n in reversed(nodes) if n.getGlobalStatus() == preferredStatus), node) return node @Slot(result="QVariantList") def allImagePaths(self): """ Get all image paths in the scene. """ return [vp.path.value for node in self._cameraInits for vp in node.viewpoints.value] def allViewIds(self): """ Get all view Ids involved in the scene. """ return [vp.viewId.value for node in self._cameraInits for vp in node.viewpoints.value] @Slot("QVariantMap", result=bool) @Slot("QVariantMap", Node, result=bool) @Slot("QVariantMap", Node, "QPoint", result=bool) def handleFilesUrl(self, filesByType, cameraInit=None, position=None): """ Handle drop events aiming to add images to the scene. 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. Node: cameraInit node used to add new images to it QPoint: position to locate the node (usually the mouse position) """ if filesByType["images"]: if cameraInit is None: if not self._cameraInits: if isinstance(position, QPoint): p = Position(position.x(), position.y()) else: p = position cameraInit = self.addNewNode("CameraInit", position=p) else: boundingBox = self.layout.boundingBox() if not position: p = Position(boundingBox[0], boundingBox[1] + boundingBox[3]) elif isinstance(position, QPoint): p = Position(position.x(), position.y()) else: p = position cameraInit = self.addNewNode("CameraInit", position=p) self._workerThreads.apply_async(func=self.importImagesSync, args=(filesByType["images"], cameraInit,)) if filesByType["videos"]: if self.nodes: boundingBox = self.layout.boundingBox() p = Position(boundingBox[0], boundingBox[1] + boundingBox[3]) else: p = position keyframeNode = self.addNewNode("KeyframeSelection", position=p) keyframeNode.inputPaths.value = filesByType["videos"] if len(filesByType["videos"]) == 1: newVideoNodeMessage = f"New node '{keyframeNode.getLabel()}' added for the input video." else: newVideoNodeMessage = f"New node '{keyframeNode.getLabel()}' added for a rig of {len(filesByType['videos'])} synchronized cameras." self.info.emit( Message( "Video Input", newVideoNodeMessage, "Warning: You need to manually compute the KeyframeSelection node \n" "and then reimport the created images into Meshroom for the reconstruction.\n\n" "If you know the Camera Make/Model, it is highly recommended to declare " "them in the Node." )) if filesByType["panoramaInfo"]: if len(filesByType["panoramaInfo"]) > 1: self.error.emit( Message( "Multiple XML files in input", "Ignore the XML Panorama files:\n\n'{}'.".format(',\n'.join(filesByType["panoramaInfo"])), "", )) else: panoramaInitNodes = self.graph.nodesOfType("PanoramaInit") for panoramaInfoFile in filesByType["panoramaInfo"]: for panoramaInitNode in panoramaInitNodes: panoramaInitNode.attribute("initializeCameras").value = "File" panoramaInitNode.attribute("config").value = panoramaInfoFile if panoramaInitNodes: self.info.emit( Message( "Panorama XML", "XML file declared on PanoramaInit node", f"XML file '{','.join(filesByType['panoramaInfo'])}' set on node '{','.join([n.getLabel() for n in panoramaInitNodes])}'", )) else: self.error.emit( Message( "No PanoramaInit Node", f"No PanoramaInit Node to set the Panorama file:\n'{','.join(filesByType['panoramaInfo'])}'.", "", )) if filesByType["meshroomScenes"]: if len(filesByType["meshroomScenes"]) > 1: self.error.emit( Message( "Too Many Meshroom Scenes", "A single Meshroom scene (.mg file) can be imported at once." ) ) else: return self.load(filesByType["meshroomScenes"][0]) if not filesByType["images"] and not filesByType["videos"] and not filesByType["panoramaInfo"] and not filesByType["meshroomScenes"]: if filesByType["other"]: extensions = {os.path.splitext(url)[1] for url in filesByType["other"]} self.error.emit( Message( "No Recognized Input File", f"No recognized input file in the {len(filesByType['other'])} dropped files", "Unknown file extensions: " + ', '.join(extensions) ) ) # As the boolean is introduced to check if the project is loaded or not, the return value is added to the function. # The default value is False, which means the project is not loaded. return False @Slot("QList", result="QVariantMap") def getFilesByTypeFromDrop(self, urls): """ Given a list of filepaths, sort them into distinct categories and return a map for all these categories. Args: 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. """ # Build the list of images paths filesByType = multiview.FilesByType() for url in urls: localFile = url.toLocalFile() if os.path.isdir(localFile): # get folder content filesByType.extend(multiview.findFilesByTypeInFolder(localFile)) else: filesByType.addFile(localFile) return {"images": filesByType.images, "videos": filesByType.videos, "panoramaInfo": filesByType.panoramaInfo, "meshroomScenes": filesByType.meshroomScenes, "other": filesByType.other} def importImagesFromFolder(self, path, recursive=False): """ Args: path: A path to a folder or file or a list of files/folders recursive: List files in folders recursively. """ logging.debug("importImagesFromFolder: " + str(path)) filesByType = multiview.findFilesByTypeInFolder(path, recursive) if not self.cameraInit: # Create a CameraInit node if none exists self.cameraInit = self.addNewNode("CameraInit") if filesByType.images: self._workerThreads.apply_async(func=self.importImagesSync, args=(filesByType.images, self.cameraInit,)) @Slot("QVariant") def importImagesUrls(self, imagePaths, recursive=False): paths = [] for imagePath in imagePaths: if isinstance(imagePath, (QUrl)): p = imagePath.toLocalFile() if not p: p = imagePath.toString() else: p = imagePath paths.append(p) self.importImagesFromFolder(paths) def importImagesSync(self, images, cameraInit): """ Add the given list of images to the scene. """ try: self.buildIntrinsics(cameraInit, images) except Exception as exc: self.importImagesFailed.emit(str(exc)) @Slot() def onImportImagesFailed(self, msg): self.error.emit( Message( "Failed to Import Images", "A corrupted image in the import set or an installation error may have caused this issue.", "" # msg ) ) def buildIntrinsics(self, cameraInit, additionalViews, rebuild=False): """ Build up-to-date intrinsics and views based on already loaded + additional images. Does not modify the graph, can be called outside the main thread. Emits intrinsicBuilt(views, intrinsics) when done. Args: cameraInit (Node): CameraInit node to build the intrinsics for additionalViews: list of additional views to add to the CameraInit viewpoints rebuild (bool): whether to rebuild already created intrinsics """ views = [] intrinsics = [] # Duplicate 'cameraInit' outside the graph. # => allows to compute intrinsics without modifying the node or the graph # If cameraInit is None: # * create an uninitialized node # * wait for the result before actually creating new nodes in the graph (see onIntrinsicsAvailable) inputs = cameraInit.toDict()["inputs"] if cameraInit else {} cameraInitCopy = Node("CameraInit", **inputs) if rebuild: # if rebuilding all intrinsics, for each Viewpoint: for vp in cameraInitCopy.viewpoints.value: vp.intrinsicId.resetToDefaultValue() # reset intrinsic assignation vp.metadata.resetToDefaultValue() # and metadata (to clear any previous 'SensorWidth' entries) # reset existing intrinsics list cameraInitCopy.intrinsics.resetToDefaultValue() try: self.setBuildingIntrinsics(True) # Retrieve the list of updated viewpoints and intrinsics views, intrinsics = cameraInitCopy.nodeDesc.buildIntrinsics(cameraInitCopy, additionalViews) except Exception as exc: logging.error(f"Error while building intrinsics: {exc}") raise finally: # Delete the duplicate cameraInitCopy.deleteLater() self.setBuildingIntrinsics(False) # always emit intrinsicsBuilt signal to inform listeners # in other threads that computation is over self.intrinsicsBuilt.emit(cameraInit, views, intrinsics, rebuild) @Slot(Node) def rebuildIntrinsics(self, cameraInit): """ Rebuild intrinsics of 'cameraInit' from scratch. Args: cameraInit (Node): the CameraInit node """ self._workerThreads.apply_async(func=self.buildIntrinsics, args=(cameraInit, (), True,)) def onIntrinsicsAvailable(self, cameraInit, views, intrinsics, rebuild=False): """ Update CameraInit with given views and intrinsics. """ commandTitle = "Add {} Images" if rebuild: commandTitle = f"Rebuild '{cameraInit.label}' Intrinsics" # No additional views: early return if not views: return commandTitle = commandTitle.format(len(views)) # allow updates between commands so that node depths (useful for auto layout) with self.groupedGraphModification(commandTitle, disableUpdates=False): with self.groupedGraphModification("Set Views and Intrinsics"): self.setAttribute(cameraInit.viewpoints, views) self.setAttribute(cameraInit.intrinsics, intrinsics) self.cameraInit = cameraInit def setBuildingIntrinsics(self, value): if self._buildingIntrinsics == value: return self._buildingIntrinsics = value self.buildingIntrinsicsChanged.emit() activeNodes = makeProperty(QObject, "_activeNodes", resetOnDestroy=True) cameraInitChanged = Signal() cameraInit = makeProperty(QObject, "_cameraInit", cameraInitChanged, resetOnDestroy=True) tempCameraInitChanged = Signal() tempCameraInit = makeProperty(QObject, "_tempCameraInit", tempCameraInitChanged, resetOnDestroy=True) cameraInitIndex = Property(int, getCameraInitIndex, setCameraInitIndex, notify=cameraInitChanged) viewpoints = Property(QObject, getViewpoints, notify=cameraInitChanged) cameraInits = Property(QObject, lambda self: self._cameraInits, constant=True) importImagesFailed = Signal(str) intrinsicsBuilt = Signal(QObject, list, list, bool) buildingIntrinsicsChanged = Signal() buildingIntrinsics = Property(bool, lambda self: self._buildingIntrinsics, notify=buildingIntrinsicsChanged) displayedAttr2DChanged = Signal() displayedAttr2D = makeProperty(QObject, "_displayedAttr2D", displayedAttr2DChanged) displayedAttrs3DChanged = Signal() displayedAttrs3D = Property(QObject, lambda self: self._displayedAttrs3D, notify=displayedAttrs3DChanged) pluginsReloaded = Signal(list, list) @Slot(QObject) def setActiveNode(self, node, categories=True, inputs=True): """ Set node as the active node of its type and of its categories. Also upgrade related input nodes. """ if categories: for category, nodeTypes in self.activeNodeCategories.items(): if node.nodeType in nodeTypes: self.activeNodes.getr(category).node = node if category == "sfm": self.setSfm(node) if node.nodeType == "CameraInit": # if the active node is a CameraInit node, update the camera init index self.setCameraInitNode(node) elif inputs: # Update the input node to ensure that it is part of the dependency of the new active node. # Retrieve all nodes that are input nodes of the new active node inputNodes = node.getInputNodes(recursive=True, dependenciesOnly=True) inputCameraInitNodes = [n for n in inputNodes if n.nodeType == "CameraInit"] # if the current camera init node is not the same as the camera init node of the active node if inputCameraInitNodes and self.cameraInit not in inputCameraInitNodes: # set the camera init node of the active node as the current camera init node # if multiple camera init, select one arbitrarily (the one with more viewpoints) inputCameraInitNodes.sort(key=lambda n: len(n.viewpoints.value), reverse=True) cameraInitNode = inputCameraInitNodes[0] self.setCameraInitNode(cameraInitNode) # Set the new active node (if it is not an unknown type) unknownType = isinstance(node, CompatibilityNode) and node.issue == CompatibilityIssue.UnknownNodeType if not unknownType: activeNode = self.activeNodes.get(node.nodeType) if activeNode: activeNode.node = node @Slot(QObject) def setActiveNodes(self, nodes): """ Set node as the active node of its type. """ for node in nodes: if node is None: continue self.setActiveNode(node, categories=False, inputs=False) def resetActiveNodePerCategory(self): # Setup the active node per category only once, on the last one nodesByCategory = {} for category, nodeTypes in self.activeNodeCategories.items(): node = self.lastNodeOfType(nodeTypes, self._cameraInit, Status.SUCCESS) self.activeNodes.get(category).node = node def updateSfMResults(self): """ Update internal views, poses and solved intrinsics based on the current SfM node. """ if not self._sfm or ('outputViewsAndPoses' not in self._sfm.getAttributes().keys()): self._views = dict() self._poses = dict() self._solvedIntrinsics = dict() else: self._views, self._poses, self._solvedIntrinsics = parseSfMJsonFile(self._sfm.outputViewsAndPoses.value) self.sfmReportChanged.emit() def getSfm(self): """ Returns the current SfM node. """ return self._sfm def _unsetSfm(self): """ Unset current SfM node. This is shortcut equivalent to _setSfm(None). """ self._setSfm(None) def _setSfm(self, node): """ Set current SfM node to 'node' and update views and poses. Notes: this should not be called directly, use setSfm instead. See Also: setSfm """ self._sfm = node # Update sfm results and do so each time # the status of the SfM node's only chunk changes self.updateSfMResults() if self._sfm: # when destroyed, directly use '_setSfm' to bypass # disconnection step in 'setSfm' (at this point, 'self._sfm' underlying object # has been destroyed and cannot be evaluated anymore) self._sfm.destroyed.connect(self._unsetSfm) if len(self._sfm._chunks) > 0: self._sfm.chunks[0].statusChanged.connect(self.updateSfMResults) self.sfmChanged.emit() def setSfm(self, node): """ Set the current SfM node. This node will be used to retrieve sparse 3D reconstruction result like camera poses. """ # disconnect from previous SfM node if any if self._sfm: self._sfm.chunks[0].statusChanged.disconnect(self.updateSfMResults) self._sfm.destroyed.disconnect(self._unsetSfm) self._setSfm(node) @Slot(QObject, result=bool) def isInViews(self, viewpoint): if not viewpoint: return False # keys are strings (faster lookup) return str(viewpoint.viewId.value) in self._views @Slot(QObject, result=bool) def isReconstructed(self, viewpoint): if not viewpoint: return False # fetch up-to-date poseId from sfm result (in case of rigs, poseId might have changed) if not self._views: return False view = self._views.get(str(viewpoint.poseId.value), None) # keys are strings (faster lookup) return view.get('poseId', -1) in self._poses if view else False @Slot(QObject, result=bool) def hasValidIntrinsic(self, viewpoint): # keys are strings (faster lookup) allIntrinsicIds = [i.intrinsicId.value for i in self._cameraInit.intrinsics.value] return viewpoint.intrinsicId.value in allIntrinsicIds @Slot(QObject, result=QObject) def getIntrinsic(self, viewpoint): """ Get the intrinsic attribute associated to 'viewpoint' based on its intrinsicId. Args: viewpoint (Attribute): the Viewpoint to consider. Returns: Attribute: the Viewpoint's corresponding intrinsic or None if not found. """ if not viewpoint: return None return next((i for i in self._cameraInit.intrinsics.value if i.intrinsicId.value == viewpoint.intrinsicId.value) , None) @Slot(QObject, result=bool) def hasMetadata(self, viewpoint): # Should be greater than 2 to avoid the particular case of "" return len(viewpoint.metadata.value) > 2 def setSelectedViewId(self, viewId): if viewId == self._selectedViewId: return self._selectedViewId = viewId self.setPickedViewId(viewId) vp = None if self.viewpoints: vp = next((v for v in self.viewpoints if str(v.viewId.value) == self._selectedViewId), None) self._setSelectedViewpoint(vp) self.selectedViewIdChanged.emit() def _setSelectedViewpoint(self, viewpointAttribute): if self._selectedViewpoint: # Scene has ownership of Viewpoint object - destroy it when not needed anymore self._selectedViewpoint.deleteLater() self._selectedViewpoint = ViewpointWrapper(viewpointAttribute, self) if viewpointAttribute else None self.selectedViewpointChanged.emit() def setPickedViewId(self, viewId): if viewId == self._pickedViewId: return self._pickedViewId = viewId self.pickedViewIdChanged.emit() @Slot(str) def updateSelectedViewpoint(self, viewId): """ Update the currently set viewpoint if the provided view ID corresponds to one. """ vp = None if self.viewpoints: vp = next((v for v in self.viewpoints if str(v.viewId.value) == viewId), None) self._setSelectedViewpoint(vp) def reconstructedCamerasCount(self): """ Get the number of reconstructed cameras in the current context. """ viewpoints = self.getViewpoints() # Check that the object is iterable to avoid error with undefined Qt Property if not isinstance(viewpoints, Iterable): return 0 return len([v for v in viewpoints if self.isReconstructed(v)]) @Slot(QObject, result="QVariant") def getSolvedIntrinsics(self, viewpoint): """ Return viewpoint's solved intrinsics if it has been reconstructed, None otherwise. Args: viewpoint: the viewpoint object to instrinsics for. """ if not viewpoint: return None return self._solvedIntrinsics.get(str(viewpoint.intrinsicId.value), None) def getPoseRT(self, viewpoint): """ Get the camera pose as rotation and translation of the given viewpoint. Args: viewpoint: the viewpoint attribute to consider. Returns: R, T: the rotation and translation as lists of floats """ if not viewpoint: return None, None view = self._views.get(str(viewpoint.viewId.value), None) if not view: return None, None pose = self._poses.get(view.get('poseId', -1), None) if not pose: return None, None pose = pose["transform"] R = [float(i) for i in pose["rotation"]] T = [float(i) for i in pose["center"]] return R, T def setCurrentViewPath(self, path): if self._currentViewPath == path: return self._currentViewPath = path self.currentViewPathChanged.emit() @Slot(str, result="QVariantList") def evaluateMathExpression(self, expr): """ Evaluate a mathematical expression and return the result as a string Returns a list of 2 values : - the result value - a boolean that indicates whether an error occurred """ mev = MathEvaluator() try: res = mev.evaluate(expr) return [res, False] except Exception as err: self.parent().showMessage(f"Invalid field expression: {expr}", "error") return [None, True] selectedViewIdChanged = Signal() selectedViewId = Property(str, lambda self: self._selectedViewId, setSelectedViewId, notify=selectedViewIdChanged) selectedViewpointChanged = Signal() selectedViewpoint = Property(ViewpointWrapper, lambda self: self._selectedViewpoint, notify=selectedViewpointChanged) pickedViewIdChanged = Signal() pickedViewId = Property(str, lambda self: self._pickedViewId, setPickedViewId, notify=pickedViewIdChanged) sfmChanged = Signal() sfm = Property(QObject, getSfm, setSfm, notify=sfmChanged) sfmReportChanged = Signal() # convenient property for QML binding re-evaluation when sfm report changes sfmReport = Property(bool, lambda self: len(self._poses) > 0, notify=sfmReportChanged) nbCameras = Property(int, reconstructedCamerasCount, notify=sfmReportChanged) # Provides the path of the image that is currently displayed # This is an alternative to "selectedViewpoint.attribute.path.value" for images that are displayed # but not part of the list of viewpoints of a CameraInit node (i.e. "sequence" node outputs) currentViewPathChanged = Signal() currentViewPath = Property(str, lambda self: self._currentViewPath, setCurrentViewPath, notify=currentViewPathChanged) # Whether the Scene object has been set ("new" has been called) or not ("new" has never # been called or "clear" has been called) activeChanged = Signal() active = Property(bool, lambda self: self._active, setActive, notify=activeChanged) # Signals to propagate high-level messages error = Signal(Message) warning = Signal(Message) info = Signal(Message) ================================================ FILE: meshroom/ui/utils.py ================================================ import os import time from PySide6.QtCore import QFileSystemWatcher, QUrl, Slot, QTimer, Property, QObject from PySide6.QtQml import QQmlApplicationEngine try: from PySide6 import shiboken6 except Exception: import shiboken6 class QmlInstantEngine(QQmlApplicationEngine): """ QmlInstantEngine is a utility class helping to develop QML applications. It reloads itself whenever one of the watched source files is modified. As it consumes resources, make sure to disable file watching in production mode. """ def __init__(self, sourceFile="", watching=True, verbose=False, parent=None): """ watching -- Defines whether the watcher is active (default: True) verbose -- if True, output log information (default: False) """ super().__init__(parent) self._fileWatcher = QFileSystemWatcher() # Internal Qt File Watcher self._sourceFile = "" self._watchedFiles = [] # Internal watched files list self._verbose = verbose # Verbose bool self._watching = False # self._extensions = ["qml", "js"] # File extensions that defines files to watch when adding a folder self._rootItem = None def onObjectCreated(root, url): if not root: return # Restore root item geometry if self._rootItem: root.setGeometry(self._rootItem.geometry()) self._rootItem.deleteLater() self._rootItem = root self.objectCreated.connect(onObjectCreated) # Update the watching status self.setWatching(watching) if sourceFile: self.load(sourceFile) def load(self, sourceFile): self._sourceFile = sourceFile super().load(sourceFile) def setWatching(self, watchValue): """ Enable (True) or disable (False) the file watching. Tip: file watching should be enable only when developing. """ if self._watching is watchValue: return self._watching = watchValue # Enable the watcher if self._watching: # 1. Add internal list of files to the internal Qt File Watcher self.addFiles(self._watchedFiles) # 2. Connect 'filechanged' signal self._fileWatcher.fileChanged.connect(self.onFileChanged) # Disabling the watcher else: # 1. Remove all files in the internal Qt File Watcher self._fileWatcher.removePaths(self._watchedFiles) # 2. Disconnect 'filechanged' signal self._fileWatcher.fileChanged.disconnect(self.onFileChanged) @property def watchedExtensions(self): """ Returns the list of extensions used when using addFilesFromDirectory. """ return self._extensions @watchedExtensions.setter def watchedExtensions(self, extensions): """ Set the list of extensions to search for when using addFilesFromDirectory. """ self._extensions = extensions def setVerbose(self, verboseValue): """ Activate (True) or deactivate (False) the verbose. """ self._verbose = verboseValue def addFile(self, filename): """ Add the given 'filename' to the watched files list. 'filename' can be an absolute or relative path (str and QUrl accepted) """ # Deal with QUrl type # NOTE: happens when using the source() method on a QQuickView if isinstance(filename, QUrl): filename = filename.path() # Make sure the file exists if not os.path.isfile(filename): raise ValueError(f"addFile: file {filename} does not exist.") # Return if the file is already in our internal list if filename in self._watchedFiles: return # Add this file to the internal files list self._watchedFiles.append(filename) # And, if watching is active, add it to the internal watcher as well if self._watching: if self._verbose: print("instantcoding: addPath", filename) self._fileWatcher.addPath(filename) def addFiles(self, filenames): """ Add the given 'filenames' to the watched files list. filenames -- a list of absolute or relative paths (str and QUrl accepted) """ # Convert to list if not isinstance(filenames, list): filenames = [filenames] for filename in filenames: self.addFile(filename) def addFilesFromDirectory(self, dirname, recursive=False): """ Add files from the given directory name 'dirname'. dirname -- an absolute or a relative path recursive -- if True, will search inside each subdirectories recursively. """ if not os.path.isdir(dirname): raise RuntimeError(f"addFilesFromDirectory : {dirname} is not a valid directory.") if recursive: for dirpath, dirnames, filenames in os.walk(dirname): for filename in filenames: # Removing the starting dot from extension if os.path.splitext(filename)[1][1:] in self._extensions: self.addFile(os.path.join(dirpath, filename)) else: filenames = os.listdir(dirname) filenames = [os.path.join(dirname, filename) for filename in filenames if os.path.splitext(filename)[1][1:] in self._extensions] self.addFiles(filenames) def removeFile(self, filename): """ Remove the given 'filename' from the watched file list. Tip: make sure to use relative or absolute path according to how you add this file. """ if filename in self._watchedFiles: self._watchedFiles.remove(filename) if self._watching: self._fileWatcher.removePath(filename) def getRegisteredFiles(self): """ Returns the list of watched files """ return self._watchedFiles @Slot(str) def onFileChanged(self, filepath): """ Handle changes in a watched file. """ if filepath not in self._watchedFiles: # could happen if a file has just been reloaded # and has not been re-added yet to the watched files return if self._verbose: print("Source file changed : ", filepath) # Clear the QQuickEngine cache self.clearComponentCache() # Remove the modified file from the watched list self.removeFile(filepath) cptTry = 0 # Make sure file is available before doing anything # NOTE: useful to handle editors (Qt Creator) that deletes the source file and # creates a new one when saving while not os.path.exists(filepath) and cptTry < 10: time.sleep(0.1) cptTry += 1 self.reload() # Finally, re-add the modified file to the watch system # after a short cooldown to avoid multiple consecutive reloads QTimer.singleShot(200, lambda: self.addFile(filepath)) def reload(self): print(f"Reloading {self._sourceFile}") self.load(self._sourceFile) def makeProperty(T, attributeName, notify=None, resetOnDestroy=False): """ Shortcut function to create a Qt Property with generic getter and setter. Getter returns the underlying attribute value. Setter sets and emit notify signal only if the given value is different from the current one. Args: T (type): the type of the property attributeName (str): the name of underlying instance attribute to get/set notify (Signal): the notify signal; if None, property will be constant resetOnDestroy (bool): Only applicable for QObject-type properties. Whether to reset property to None when current value gets destroyed. Examples: class Foo(QObject): _bar = 10 barChanged = Signal() # read/write bar = makeProperty(int, "_bar", notify=barChanged) # read only (constant) bar = makeProperty(int, "_bar") Returns: Property: the created Property """ def setter(instance, value): """ Generic setter. """ currentValue = getattr(instance, attributeName) if currentValue == value: return resetCallbackName = '__reset__' + attributeName if resetOnDestroy and not hasattr(instance, resetCallbackName): # store reset callback on instance, only way to keep a reference to this function # that can be used for destroyed signal (dis)connection setattr(instance, resetCallbackName, lambda self=instance, *args: setter(self, None)) resetCallback = getattr(instance, resetCallbackName, None) if resetCallback and currentValue and shiboken6.isValid(currentValue): currentValue.destroyed.disconnect(resetCallback) setattr(instance, attributeName, value) if resetCallback and value: value.destroyed.connect(resetCallback) getattr(instance, signalName(notify)).emit() def getter(instance): """ Generic getter. """ return getattr(instance, attributeName) def signalName(signalInstance): """ Get signal name from instance. """ # string representation contains trailing '()', remove it return str(signalInstance)[:-2] if resetOnDestroy and not issubclass(T, QObject): raise RuntimeError("destroyCallback can only be used with QObject-type properties.") if notify: return Property(T, getter, setter, notify=notify) else: return Property(T, getter, constant=True) ================================================ FILE: requirements.txt ================================================ # runtime psutil>=5.6.7 PySide6==6.8.3 markdown==2.6.11 requests==2.32.4 pyseq==0.9.0 ================================================ FILE: setup.py ================================================ import platform import os import setuptools # for bdist from cx_Freeze import setup, Executable import meshroom currentDir = os.path.dirname(os.path.abspath(__file__)) class PlatformExecutable(Executable): """ Extend cx_Freeze.Executable to handle platform variations. """ Windows = "Windows" Linux = "Linux" Darwin = "Darwin" exeExtensions = { Windows: ".exe", Linux: "", Darwin: ".app" } def __init__(self, script, initScript=None, base=None, targetName=None, icons=None, shortcutName=None, shortcutDir=None, copyright=None, trademarks=None): # despite supposed to be optional, targetName is actually required on some configurations if not targetName: targetName = os.path.splitext(os.path.basename(script))[0] # add platform extension to targetName targetName += PlatformExecutable.exeExtensions[platform.system()] # get icon for platform if defined icon = icons.get(platform.system(), None) if icons else None if platform.system() in (self.Linux, self.Darwin): initScript = os.path.join(currentDir, "setupInitScriptUnix.py") elif platform.system() is self.Windows: initScript = os.path.join(currentDir, "setupInitScriptWindows.py") super(PlatformExecutable, self).__init__(script, initScript, base, targetName, icon, shortcutName, shortcutDir, copyright, trademarks) build_exe_options = { # include dynamically loaded plugins "packages": ["meshroom.nodes", "meshroom.submitters"], "includes": [ "idna.idnadata", # Dependency needed by SketchfabUpload node, but not detected by cx_Freeze "timeit", "pickletools", "modulefinder", "cProfile", "colorsys", "xml.dom.minidom", "http.cookies", "filecmp", "logging.handlers", "cmath", "numpy" ], "include_files": ["CHANGES.md", "COPYING.md", "LICENSE-MPL2.md", "README.md", "bin"] } if os.path.isdir(os.path.join(currentDir, "tractor")): build_exe_options["packages"].append("tractor") if os.path.isdir(os.path.join(currentDir, "simpleFarm")): build_exe_options["packages"].append("simpleFarm") if platform.system() == PlatformExecutable.Linux: # include required system libs # from https://github.com/Ultimaker/cura-build/blob/master/packaging/setup_linux.py.in build_exe_options.update({ "bin_path_includes": [ "/lib", "/lib64", "/usr/lib", "/usr/lib64", ], "bin_includes": [ "libssl3", "libssl", "libcrypto", ], "bin_excludes": [ "linux-vdso.so", "libpthread.so", "libdl.so", "librt.so", "libstdc++.so", "libm.so", "libgcc_s.so", "libc.so", "ld-linux-x86-64.so", "libz.so", "libgcc_s.so", "libglib-2", "librt.so", "libcap.so", "libGL.so", "libglapi.so", "libXext.so", "libXdamage.so", "libXfixes.so", "libX11-xcb.so", "libX11.so", "libxcb-glx.so", "libxcb-dri2.so", "libxcb.so", "libXxf86vm.so", "libdrm.so", "libexpat.so", "libXau.so", "libglib-2.0.so", "libgssapi_krb5.so", "libgthread-2.0.so", "libk5crypto.so", "libkeyutils.so", "libkrb5.so", "libkrb5support.so", "libresolv.so", "libutil.so", "libXrender.so", "libcom_err.so", "libgssapi_krb5.so", ] }) executables = [ # GUI PlatformExecutable( "meshroom/ui/__main__.py", targetName="Meshroom", icons={PlatformExecutable.Windows: "meshroom/ui/img/meshroom.ico"} ), # Command line PlatformExecutable("bin/meshroom_batch"), PlatformExecutable("bin/meshroom_compute"), PlatformExecutable("bin/meshroom_newNodeType"), PlatformExecutable("bin/meshroom_statistics"), PlatformExecutable("bin/meshroom_status"), PlatformExecutable("bin/meshroom_submit"), ] setup( name="Meshroom", description="Meshroom", install_requires=["psutil", "PySide6", "markdown"], setup_requires=[ "cx_Freeze" ], version=meshroom.__version__, options={"build_exe": build_exe_options}, executables=executables, ) ================================================ FILE: setupInitScriptUnix.py ================================================ # ------------------------------------------------------------------------------ # ConsoleSetLibPath.py # Initialization script for cx_Freeze which manipulates the path so that the # directory in which the executable is found is searched for extensions but # no other directory is searched. The environment variable LD_LIBRARY_PATH is # manipulated first, however, to ensure that shared libraries found in the # target directory are found. This requires a restart of the executable because # the environment variable LD_LIBRARY_PATH is only checked at startup. # ------------------------------------------------------------------------------ import os import sys import zipimport FILE_NAME = sys.executable DIR_NAME = os.path.dirname(sys.executable) paths = os.environ.get("LD_LIBRARY_PATH", "").split(os.pathsep) if DIR_NAME not in paths: paths.insert(0, DIR_NAME) paths.insert(0, os.path.join(DIR_NAME, "lib")) paths.insert(0, os.path.join(DIR_NAME, "aliceVision", "lib")) paths.insert(0, os.path.join(DIR_NAME, "aliceVision", "lib64")) paths.insert(0, os.path.join(DIR_NAME, "lib", "PySide6", "Qt", "qml", "QtQuick", "Dialogs")) os.environ["LD_LIBRARY_PATH"] = os.pathsep.join(paths) os.environ["PYTHONPATH"] = os.path.join(DIR_NAME, "aliceVision", "lib", "python") + os.pathsep + os.path.join(DIR_NAME, "aliceVision", "lib", "python3.11", "site-packages") os.execv(sys.executable, sys.argv) sys.frozen = True sys.path = sys.path[:6] def run(*args): m = __import__("__main__") importer = zipimport.zipimporter(DIR_NAME + "/lib/library.zip") if len(args) == 0: name, ext = os.path.splitext(os.path.basename(os.path.normcase(FILE_NAME))) moduleName = "%s__main__" % name else: moduleName = args[0] pythonPaths = os.getenv("PYTHONPATH", "").split(os.pathsep) for p in pythonPaths: sys.path.append(p) code = importer.get_code(moduleName) exec(code, m.__dict__) ================================================ FILE: setupInitScriptWindows.py ================================================ import os import subprocess import sys import zipimport FILE_NAME = sys.executable DIR_NAME = os.path.dirname(sys.executable) paths = os.environ.get("ALICEVISION_LIBPATH", "").split(os.pathsep) if DIR_NAME not in paths: paths.insert(0, DIR_NAME) paths.insert(0, os.path.join(DIR_NAME, "lib")) paths.insert(0, os.path.join(DIR_NAME, "aliceVision", "lib")) paths.insert(0, os.path.join(DIR_NAME, "aliceVision", "bin")) os.environ["ALICEVISION_LIBPATH"] = os.pathsep.join(paths) os.environ["PYTHONPATH"] = os.path.join(DIR_NAME, "aliceVision", "lib", "python") + os.pathsep + os.path.join(DIR_NAME, "aliceVision", "lib", "python3.11", "site-packages") sys.exit(subprocess.call([sys.executable] + sys.argv[1:])) sys.frozen = True sys.path = sys.path[:5] def run(*args): m = __import__("__main__") importer = zipimport.zipimporter(DIR_NAME + "/lib/library.zip") if len(args) == 0: name, ext = os.path.splitext(os.path.basename(os.path.normcase(FILE_NAME))) moduleName = "%s__main__" % name else: moduleName = args[0] pythonPaths = os.getenv("PYTHONPATH", "").split(os.pathsep) for p in pythonPaths: sys.path.append(p) code = importer.get_code(moduleName) exec(code, m.__dict__) ================================================ FILE: start.bat ================================================ REM Windows REM Add the aliceVision and qtPlugins folders with the binaries to this directory set MESHROOM_INSTALL_DIR=%CD% set PYTHONPATH=%CD% REM # Development options REM set MESHROOM_OUTPUT_QML_WARNINGS=1 REM set MESHROOM_INSTANT_CODING=1 REM set QT_PLUGIN_PATH=C:\dev\meshroom\install REM set QML2_IMPORT_PATH=C:\dev\meshroom\install\qml REM set ALICEVISION_ROOT=C:\dev\AliceVision\install REM set ALICEVISION_LIBPATH=%ALICEVISION_ROOT%\bin;C:\dev\vcpkg\installed\x64-windows\bin REM PYTHONPATH=%ALICEVISION_ROOT%\lib\python;%PYTHONPATH% python meshroom\ui ================================================ FILE: start.sh ================================================ #!/bin/bash export MESHROOM_ROOT="$(dirname "$(readlink -f "${BASH_SOURCE[0]}" )" )" export PYTHONPATH=$MESHROOM_ROOT:$PYTHONPATH # using existing alicevision release #export LD_LIBRARY_PATH=/foo/Meshroom-2023.2.0/aliceVision/lib/ #export PATH=$PATH:/foo/Meshroom-2023.2.0/aliceVision/bin/ # using alicevision built source #export PATH=$PATH:/foo/build/Linux-x86_64/ python3 "$MESHROOM_ROOT/meshroom/ui" ================================================ FILE: tests/__init__.py ================================================ import os from meshroom.core import loadAllNodes from meshroom.core import pluginManager plugins = loadAllNodes(os.path.join(os.path.dirname(__file__), "nodes")) for plugin in plugins: pluginManager.addPlugin(plugin) if os.getenv("MESHROOM_PIPELINE_TEMPLATES_PATH", False): os.environ["MESHROOM_PIPELINE_TEMPLATES_PATH"] += os.pathsep + os.path.dirname(os.path.realpath(__file__)) else: os.environ["MESHROOM_PIPELINE_TEMPLATES_PATH"] = os.path.dirname(os.path.realpath(__file__)) ================================================ FILE: tests/appendTextAndFiles.mg ================================================ { "header": { "releaseVersion": "2025.1.0-develop", "fileVersion": "2.0", "nodesVersions": {}, "template": true }, "graph": { "AppendFiles_1": { "nodeType": "AppendFiles", "position": [ 189, 8 ], "inputs": { "input": "{AppendText_1.output}", "input2": "{AppendText_2.output}", "input3": "{AppendText_1.input}", "input4": "{AppendText_2.input}" } }, "AppendText_1": { "nodeType": "AppendText", "position": [ 0, 0 ], "inputs": { "inputText": "Input text from AppendText_1" } }, "AppendText_2": { "nodeType": "AppendText", "position": [ 0, 160 ], "inputs": { "inputText": "Input text from AppendText_2" } } } } ================================================ FILE: tests/conftest.py ================================================ import tempfile import pytest from meshroom.core.graph import Graph @pytest.fixture def graphSavedOnDisk(): """ Yield a Graph instance saved in a unique temporary folder. Can be used for testing graph IO and computation in isolation. """ with tempfile.TemporaryDirectory() as cacheDir: graph = Graph() graph.saveAsTemp(cacheDir) yield graph ================================================ FILE: tests/nodes/__init__.py ================================================ ================================================ FILE: tests/nodes/test/Color.py ================================================ from meshroom.core import desc class Color(desc.Node): inputs = [ desc.GroupAttribute( name="rgb", label="rgb", description="rgb", exposed=True, items=[ desc.FloatParam(name="r", label="r", description="r", value=0.0), desc.FloatParam(name="g", label="g", description="g", value=0.0), desc.FloatParam(name="b", label="b", description="b", value=0.0), ], ), ] class NestedColor(desc.Node): inputs = [ desc.GroupAttribute( name="rgb", label="rgb", description="rgb", exposed=True, items=[ desc.FloatParam(name="r", label="r", description="r", value=0.0), desc.FloatParam(name="g", label="g", description="g", value=0.0), desc.FloatParam(name="b", label="b", description="b", value=0.0), desc.GroupAttribute(label="test", name="test", description="", items=[ desc.FloatParam(name="r", label="r", description="r", value=0.0), desc.FloatParam(name="g", label="g", description="g", value=0.0), desc.FloatParam(name="b", label="b", description="b", value=0.0), ], ), ], ), ] ================================================ FILE: tests/nodes/test/GroupAttributes.py ================================================ from meshroom.core import desc class GroupAttributes(desc.Node): documentation = """ Test node to connect GroupAttributes to other GroupAttributes. """ # Inputs to the node inputs = [ desc.GroupAttribute( name="firstGroup", label="First Group", description="Group at the root level.", commandLineGroup=None, exposed=True, items=[ desc.IntParam( name="firstGroupIntA", label="Integer A", description="First integer in group.", value=1024, range=(-1, 2000, 10), exposed=True, ), desc.BoolParam( name="firstGroupBool", label="Boolean", description="Boolean in group.", value=True, advanced=True, exposed=True, ), desc.ChoiceParam( name="firstGroupExclusiveChoiceParam", label="Exclusive Choice Param", description="Exclusive choice parameter.", value="one", values=["one", "two", "three", "four"], exclusive=True, exposed=True, ), desc.ChoiceParam( name="firstGroupChoiceParam", label="ChoiceParam", description="Non-exclusive choice parameter.", value=["one", "two"], values=["one", "two", "three", "four"], exclusive=False, exposed=True ), desc.GroupAttribute( name="nestedGroup", label="Nested Group", description="A group within a group.", commandLineGroup=None, exposed=True, items=[ desc.FloatParam( name="nestedGroupFloat", label="Floating Number", description="Floating number in group.", value=1.0, range=(0.0, 100.0, 0.01), exposed=True ), ], ), desc.ListAttribute( name="groupedList", label="Grouped List", description="List of groups within a group.", advanced=True, exposed=True, elementDesc=desc.GroupAttribute( name="listedGroup", label="Listed Group", description="Group in a list within a group.", joinChar=":", commandLineGroup=None, items=[ desc.IntParam( name="listedGroupInt", label="Integer 1", description="Integer in a group in a list within a group.", value=12, range=(3, 24, 1), exposed=True, ), ], ), ), desc.ListAttribute( name="singleGroupedList", label="Grouped List With Single Element", description="List of integers within a group.", advanced=True, exposed=True, elementDesc=desc.IntParam( name="listedInt", label="Integer In List", description="Integer in a list within a group.", value=40, ), ), ], ), desc.IntParam( name="exposedInt", label="Exposed Integer", description="Integer at the root level, exposed.", value=1000, exposed=True, ), desc.BoolParam( name="unexposedBool", label="Unexposed Boolean", description="Boolean at the root level, unexposed.", value=True, ), desc.GroupAttribute( name="inputGroup", label="Input Group", description="A group set as an input.", commandLineGroup=None, items=[ desc.BoolParam( name="inputBool", label="Input Bool", description="", value=False, ), ], ), ] outputs = [ desc.GroupAttribute( name="outputGroup", label="Output Group", description="A group set as an output.", commandLineGroup=None, exposed=True, items=[ desc.BoolParam( name="outputBool", label="Output Bool", description="", value=False, exposed=True, ), ], ), ] ================================================ FILE: tests/nodes/test/InputDynamicOutputs.py ================================================ from meshroom.core import desc class InputDynamicOutputs(desc.InputNode): inputs = [ desc.File( name="fileInput", label="File Input", description="A file input.", value="testFile", ), ] outputs = [ desc.File( name="fileOutput", label="File Output", description="A file Output.", value=None, ), ] ================================================ FILE: tests/nodes/test/NestedTest.py ================================================ from meshroom.core import desc class NestedTest(desc.Node): inputs = [ desc.GroupAttribute( name="xyz", label="xyz", description="xyz", exposed=True, items=[ desc.FloatParam(name="x", label="x", description="x", value=0.0), desc.FloatParam(name="y", label="z", description="z", value=0.0), desc.FloatParam(name="z", label="z", description="z", value=0.0), desc.GroupAttribute(label="test", name="test", description="", items=[ desc.StringParam(name="x", label="x", description="x", value="test"), desc.FloatParam(name="y", label="z", description="z", value=0.0), desc.FloatParam(name="z", label="z", description="z", value=0.0), ], ), ], ), ] ================================================ FILE: tests/nodes/test/Position.py ================================================ from meshroom.core import desc class Position(desc.Node): inputs = [ desc.GroupAttribute( name="xyz", label="xyz", description="xyz", exposed=True, items=[ desc.FloatParam(name="x", label="x", description="x", value=0.0), desc.FloatParam(name="y", label="y", description="y", value=0.0), desc.FloatParam(name="z", label="z", description="z", value=0.0), ], ), ] class NestedPosition(desc.Node): inputs = [ desc.GroupAttribute( name="xyz", label="xyz", description="xyz", exposed=True, items=[ desc.FloatParam(name="x", label="x", description="x", value=0.0), desc.FloatParam(name="y", label="y", description="y", value=0.0), desc.FloatParam(name="z", label="z", description="z", value=0.0), desc.GroupAttribute(label="test", name="test", description="", items=[ desc.FloatParam(name="x", label="x", description="x", value=0.0), desc.FloatParam(name="y", label="y", description="y", value=0.0), desc.FloatParam(name="z", label="z", description="z", value=0.0), ], ), ], ), ] ================================================ FILE: tests/nodes/test/__init__.py ================================================ ================================================ FILE: tests/nodes/test/appendFiles.py ================================================ from meshroom.core import desc class AppendFiles(desc.CommandLineNode): commandLine = 'cat {inputValue} {input2Value} {input3Value} {input4Value} > {outputValue}' inputs = [ desc.File( name='input', label='Input File', description='''''', value='', ), desc.File( name='input2', label='Input File 2', description='''''', value='', ), desc.File( name='input3', label='Input File 3', description='''''', value='', ), desc.File( name='input4', label='Input File 4', description='''''', value='', ), ] outputs = [ desc.File( name='output', label='Output', description='''''', value='{nodeCacheFolder}/appendText.txt', ) ] ================================================ FILE: tests/nodes/test/appendText.py ================================================ from meshroom.core import desc class AppendText(desc.CommandLineNode): commandLine = 'cat {inputValue} > {outputValue} && echo {inputTextValue} >> {outputValue}' inputs = [ desc.File( name='input', label='Input File', description='''''', value='', ), desc.File( name='inputText', label='Input Text', description='''''', value='', ) ] outputs = [ desc.File( name='output', label='Output', description='''''', value='{nodeCacheFolder}/appendText.txt', ), ] ================================================ FILE: tests/nodes/test/ls.py ================================================ from meshroom.core import desc class Ls(desc.CommandLineNode): commandLine = "ls {inputValue} > {outputValue}" inputs = [ desc.File( name="input", label="Input", description="", value="", ) ] outputs = [ desc.File( name="output", label="Output", description="", value="{nodeCacheFolder}/ls.txt", ) ] ================================================ FILE: tests/plugins/meshroom/pluginA/PluginAInputInitNode.py ================================================ __version__ = "1.0" from meshroom.core import desc class PluginAInputInitNode(desc.InputNode, desc.InitNode): inputs = [ desc.File( name="input", label="Input", description="", value="", ), ] outputs = [ desc.File( name="output", label="Output", description="", value="", ), ] def initialize(self, node, inputs, recursiveInputs): if len(inputs) >= 1: self.setAttributes(node, {"input": inputs[0]}) ================================================ FILE: tests/plugins/meshroom/pluginA/PluginAInputNode.py ================================================ __version__ = "1.0" from meshroom.core import desc class PluginAInputNode(desc.InputNode): inputs = [ desc.File( name="input", label="Input", description="", value="", ), ] outputs = [ desc.File( name="output", label="Output", description="", value="", ), ] ================================================ FILE: tests/plugins/meshroom/pluginA/PluginANodeA.py ================================================ __version__ = "1.0" import time from meshroom.core import desc class PluginANodeA(desc.Node): inputs = [ desc.File( name="input", label="Input", description="", value="", ), ] outputs = [ desc.File( name="output", label="Output", description="", value=None, ), ] def process(self, node): time.sleep(3) # Simulates a long process node.output.value = node.input.value + "_value" ================================================ FILE: tests/plugins/meshroom/pluginA/PluginANodeB.py ================================================ __version__ = "1.0" import time from meshroom.core import desc class PluginANodeB(desc.Node): inputs = [ desc.File( name="input", label="Input", description="", value="", ), desc.IntParam( name="int", label="Integer", description="", value=1, ), ] outputs = [ desc.File( name="output", label="Output", description="", value="", ), ] def process(self, node): time.sleep(3) # Simulates a long process ================================================ FILE: tests/plugins/meshroom/pluginA/__init__.py ================================================ ================================================ FILE: tests/plugins/meshroom/pluginB/PluginBNodeA.py ================================================ __version__ = "1.0" from meshroom.core import desc class PluginBNodeA(desc.Node): inputs = [ desc.File( name="input", label="Input", description="", value="", ), ] outputs = [ desc.File( name="output", label="Output", description="", value="", ), ] ================================================ FILE: tests/plugins/meshroom/pluginB/PluginBNodeB.py ================================================ __version__ = "1.0" from meshroom.core import desc class PluginBNodeB(desc.Node): inputs = [ desc.File( name="input", label="Input", description="", value="", ), desc.IntParam( name="int", label="Integer", description="", value="not an integer", ), ] outputs = [ desc.File( name="output", label="Output", description="", value="", ), ] ================================================ FILE: tests/plugins/meshroom/pluginB/__init__.py ================================================ ================================================ FILE: tests/plugins/meshroom/pluginC/PluginCNodeA.py ================================================ __version__ = "1.0" __license__ = "no-license" from meshroom.core import desc class PluginCNodeA(desc.Node): """PluginCNodeA""" author = "testAuthor" inputs = [ desc.File( name="input", label="Input", description="", value="", ), ] outputs = [ desc.File( name="output", label="Output", description="", value="", ), ] ================================================ FILE: tests/plugins/meshroom/pluginC/__init__.py ================================================ ================================================ FILE: tests/plugins/meshroom/pluginSubmitter/PluginSubmitter.py ================================================ __version__ = "1.0" import logging from meshroom.core import desc LOGGER = logging.getLogger("TestSubmit") class PluginSubmitterA(desc.BaseNode): """ Test process no parallelization """ parallelization = None inputs = [ desc.IntParam( name="input", label="Input", description="input", value=1, ), ] outputs = [ desc.IntParam( name="output", label="Output", description="Output", value=None, ), ] def processChunk(self, chunk): iteration = chunk.range.iteration nbBlocks = chunk.range.nbBlocks LOGGER.info(f"> Process chunk {iteration}/{nbBlocks}") LOGGER.info(f"> Done") class PluginSubmitterB(PluginSubmitterA): """ Test process with parallelization adn static node size """ size = desc.StaticNodeSize(2) parallelization = desc.Parallelization(blockSize=1) class PluginSubmitterC(PluginSubmitterA): """ Test process with parallelization and dynamic node size """ size = desc.DynamicNodeSize("input") parallelization = desc.Parallelization(blockSize=1) ================================================ FILE: tests/plugins/meshroom/pluginSubmitter/__init__.py ================================================ ================================================ FILE: tests/plugins/meshroom/sharedTemplate.mg ================================================ { "header": { "releaseVersion": "2025.1.0-develop", "fileVersion": "2.0", "nodesVersions": {}, "template": true }, "graph": { } } ================================================ FILE: tests/test_attributeChoiceParam.py ================================================ from meshroom.core import desc from meshroom.core.graph import Graph, loadGraph from .utils import registerNodeDesc, unregisterNodeDesc class NodeWithChoiceParams(desc.Node): inputs = [ desc.ChoiceParam( name="choice", label="Choice Default Serialization", description="A choice parameter with standard serialization", value="A", values=["A", "B", "C"], saveValuesOverride=False, exclusive=True, exposed=True, ), desc.ChoiceParam( name="choiceMulti", label="Choice Default Serialization", description="A choice parameter with standard serialization", value=["A"], values=["A", "B", "C"], saveValuesOverride=False, exclusive=False, exposed=True, ), ] class NodeWithChoiceParamsSavingValuesOverride(desc.Node): inputs = [ desc.ChoiceParam( name="choice", label="Choice Custom Serialization", description="A choice parameter with serialization of overriden values", value="A", values=["A", "B", "C"], saveValuesOverride=True, exclusive=True, exposed=True, ), desc.ChoiceParam( name="choiceMulti", label="Choice Custom Serialization", description="A choice parameter with serialization of overriden values", value=["A"], values=["A", "B", "C"], saveValuesOverride=True, exclusive=False, exposed=True, ) ] class TestChoiceParam: @classmethod def setup_class(cls): registerNodeDesc(NodeWithChoiceParams) @classmethod def teardown_class(cls): unregisterNodeDesc(NodeWithChoiceParams) def test_customValueIsSerialized(self, graphSavedOnDisk): graph: Graph = graphSavedOnDisk node = graph.addNewNode(NodeWithChoiceParams.__name__) node.choice.value = "CustomValue" graph.save() loadedGraph = loadGraph(graph.filepath) assert loadedGraph.node(node.name).choice.value == "CustomValue" def test_customMultiValueIsSerialized(self, graphSavedOnDisk): graph: Graph = graphSavedOnDisk node = graph.addNewNode(NodeWithChoiceParams.__name__) node.choiceMulti.value = ["custom", "value"] graph.save() loadedGraph = loadGraph(graph.filepath) assert loadedGraph.node(node.name).choiceMulti.value == ["custom", "value"] def test_overridenValuesAreNotSerialized(self, graphSavedOnDisk): graph: Graph = graphSavedOnDisk node = graph.addNewNode(NodeWithChoiceParams.__name__) node.choice.values = ["D", "E", "F"] graph.save() loadedGraph = loadGraph(graph.filepath) assert loadedGraph.node(node.name).choice.values == ["A", "B", "C"] def test_connectionPropagatesOverridenValues(self): graph = Graph("") nodeA = graph.addNewNode(NodeWithChoiceParams.__name__) nodeB = graph.addNewNode(NodeWithChoiceParams.__name__) nodeA.choice.values = ["D", "E", "F"] nodeA.choice.connectTo(nodeB.choice) assert nodeB.choice.values == ["D", "E", "F"] def test_connectionsAreSerialized(self, graphSavedOnDisk): graph: Graph = graphSavedOnDisk nodeA = graph.addNewNode(NodeWithChoiceParams.__name__) nodeB = graph.addNewNode(NodeWithChoiceParams.__name__) nodeA.choice.connectTo(nodeB.choice) nodeA.choiceMulti.connectTo(nodeB.choiceMulti) graph.save() loadedGraph = loadGraph(graph.filepath) loadedNodeA = loadedGraph.node(nodeA.name) loadedNodeB = loadedGraph.node(nodeB.name) assert loadedNodeB.choice.inputLink == loadedNodeA.choice assert loadedNodeB.choiceMulti.inputLink == loadedNodeA.choiceMulti class TestChoiceParamSavingCustomValues: @classmethod def setup_class(cls): registerNodeDesc(NodeWithChoiceParamsSavingValuesOverride) @classmethod def teardown_class(cls): unregisterNodeDesc(NodeWithChoiceParamsSavingValuesOverride) def test_customValueIsSerialized(self, graphSavedOnDisk): graph: Graph = graphSavedOnDisk node = graph.addNewNode(NodeWithChoiceParamsSavingValuesOverride.__name__) node.choice.value = "CustomValue" node.choiceMulti.value = ["custom", "value"] graph.save() loadedGraph = loadGraph(graph.filepath) assert loadedGraph.node(node.name).choice.value == "CustomValue" assert loadedGraph.node(node.name).choiceMulti.value == ["custom", "value"] def test_overridenValuesAreSerialized(self, graphSavedOnDisk): graph: Graph = graphSavedOnDisk node = graph.addNewNode(NodeWithChoiceParamsSavingValuesOverride.__name__) node.choice.values = ["D", "E", "F"] node.choiceMulti.values = ["D", "E", "F"] graph.save() loadedGraph = loadGraph(graph.filepath) loadedNode = loadedGraph.node(node.name) assert loadedNode.choice.values == ["D", "E", "F"] assert loadedNode.choiceMulti.values == ["D", "E", "F"] def test_connectionsAreSerialized(self, graphSavedOnDisk): graph: Graph = graphSavedOnDisk nodeA = graph.addNewNode(NodeWithChoiceParamsSavingValuesOverride.__name__) nodeB = graph.addNewNode(NodeWithChoiceParamsSavingValuesOverride.__name__) nodeA.choice.connectTo(nodeB.choice) nodeA.choiceMulti.connectTo(nodeB.choiceMulti) graph.save() loadedGraph = loadGraph(graph.filepath) loadedNodeA = loadedGraph.node(nodeA.name) loadedNodeB = loadedGraph.node(nodeB.name) assert loadedNodeB.choice.inputLink == loadedNodeA.choice assert loadedNodeB.choiceMulti.inputLink == loadedNodeA.choiceMulti ================================================ FILE: tests/test_attributeDescDefaults.py ================================================ """ Tests for optional label/description/value arguments on attribute descriptors. Covers: - All param types can be created with minimal arguments (name only) - Param descriptors created without a value are marked as dynamic (isDynamicValue=True) - Output attributes with isDynamicValue=True have None as their runtime value - Input attributes without a value return the expected type default at runtime """ import pytest from meshroom.core import desc from meshroom.core.graph import Graph from .utils import registerNodeDesc, unregisterNodeDesc # --------------------------------------------------------------------------- # A node whose inputs all use the descriptor default (value=None). # Each typed param will have its type's zero-value as the runtime default. # --------------------------------------------------------------------------- class NodeWithMinimalInputs(desc.Node): inputs = [ desc.File(name="fileInput"), desc.BoolParam(name="boolInput"), desc.IntParam(name="intInput"), desc.FloatParam(name="floatInput"), desc.StringParam(name="stringInput"), desc.ColorParam(name="colorInput"), desc.ChoiceParam(name="choiceInput", values=["a", "b", "c"]), ] outputs = [] def process(self, node): pass # --------------------------------------------------------------------------- # A node whose outputs all use the descriptor default (value=None) so they # are treated as dynamic values computed at runtime. # --------------------------------------------------------------------------- class NodeWithDynamicOutputsMinimal(desc.Node): inputs = [] outputs = [ desc.File(name="fileOutput"), desc.BoolParam(name="boolOutput"), desc.IntParam(name="intOutput"), desc.FloatParam(name="floatOutput"), desc.StringParam(name="stringOutput"), ] def process(self, node): pass # --------------------------------------------------------------------------- # Tests on the descriptor objects themselves (no Graph required) # --------------------------------------------------------------------------- # Pairs of (descriptor, expected_label_from_name) _MINIMAL_DESCS = [ desc.File(name="outputFile"), desc.BoolParam(name="myBool"), desc.IntParam(name="intValue"), desc.FloatParam(name="floatValue"), desc.StringParam(name="stringParam"), desc.ColorParam(name="primaryColor"), desc.PushButtonParam(name="applyButton"), desc.ChoiceParam(name="modeChoice"), desc.ListAttribute(desc.StringParam(name="elem"), name="itemList"), desc.GroupAttribute([], name="optionGroup"), ] @pytest.mark.parametrize("attrDesc", _MINIMAL_DESCS, ids=lambda d: type(d).__name__) def test_param_minimal_creation(attrDesc): """All attribute types should be constructible with minimal arguments (name only).""" assert attrDesc.name is not None assert attrDesc.label != "" # label is auto-generated from the name assert attrDesc.description == "" # description defaults to empty string @pytest.mark.parametrize("attrDesc", [ desc.File(name="fileParam"), desc.BoolParam(name="boolParam"), desc.IntParam(name="intParam"), desc.FloatParam(name="floatParam"), desc.StringParam(name="stringParam"), desc.ColorParam(name="colorParam"), desc.PushButtonParam(name="pushButton"), desc.ChoiceParam(name="choiceParam"), ], ids=lambda d: type(d).__name__) def test_param_no_value_is_dynamic(attrDesc): """Param descriptors created without a value should be marked as dynamic.""" assert attrDesc.isDynamicValue is True def test_list_and_group_attributes_not_dynamic(): """ListAttribute and GroupAttribute always have a non-None default value.""" la = desc.ListAttribute(desc.StringParam(name="elem"), name="items") ga = desc.GroupAttribute([], name="group") assert la.isDynamicValue is False assert ga.isDynamicValue is False def test_label_auto_generated_from_camel_case(): """Label should be auto-generated from camelCase attribute names.""" assert desc.File(name="outputFile").label == "Output File" assert desc.IntParam(name="frameCount").label == "Frame Count" assert desc.BoolParam(name="useGPU").label == "Use GPU" def test_label_auto_generated_from_snake_case(): """Label should be auto-generated from snake_case attribute names.""" assert desc.StringParam(name="input_path").label == "Input Path" assert desc.FloatParam(name="min_value").label == "Min Value" def test_explicit_label_overrides_auto_generated(): """An explicitly provided label should take precedence over the auto-generated one.""" attr = desc.File(name="outputFile", label="My Custom Label") assert attr.label == "My Custom Label" def test_explicit_description_preserved(): """An explicitly provided description should be stored as-is.""" attr = desc.IntParam(name="count", description="Number of items to process.") assert attr.description == "Number of items to process." # --------------------------------------------------------------------------- # Tests on attribute runtime instances (require a Graph / node) # --------------------------------------------------------------------------- class TestInputParamDefaults: """Input params with no explicit value should use the type's zero/empty default.""" @classmethod def setup_class(cls): registerNodeDesc(NodeWithMinimalInputs) @classmethod def teardown_class(cls): unregisterNodeDesc(NodeWithMinimalInputs) @pytest.fixture def node(self): graph = Graph("") return graph.addNewNode(NodeWithMinimalInputs.__name__) def test_file_input_default(self, node): assert node.fileInput.value == "" def test_bool_input_default(self, node): assert node.boolInput.value is False def test_int_input_default(self, node): assert node.intInput.value == 0 def test_float_input_default(self, node): assert node.floatInput.value == 0.0 def test_string_input_default(self, node): assert node.stringInput.value == "" def test_color_input_default(self, node): assert node.colorInput.value == "" def test_choice_input_default(self, node): # ChoiceParam with string values → _valueType=str → str() = "" assert node.choiceInput.value == "" class TestOutputParamDynamicValue: """Output params created without a default value should be dynamic (None at runtime).""" @classmethod def setup_class(cls): registerNodeDesc(NodeWithDynamicOutputsMinimal) @classmethod def teardown_class(cls): unregisterNodeDesc(NodeWithDynamicOutputsMinimal) @pytest.fixture def node(self): graph = Graph("") return graph.addNewNode(NodeWithDynamicOutputsMinimal.__name__) def test_output_desc_is_dynamic(self, node): assert node.fileOutput.desc.isDynamicValue is True assert node.boolOutput.desc.isDynamicValue is True assert node.intOutput.desc.isDynamicValue is True assert node.floatOutput.desc.isDynamicValue is True assert node.stringOutput.desc.isDynamicValue is True def test_file_output_value_is_none(self, node): assert node.fileOutput.value is None def test_bool_output_value_is_none(self, node): assert node.boolOutput.value is None def test_int_output_value_is_none(self, node): assert node.intOutput.value is None def test_float_output_value_is_none(self, node): assert node.floatOutput.value is None def test_string_output_value_is_none(self, node): assert node.stringOutput.value is None ================================================ FILE: tests/test_attributeKeyValues.py ================================================ from meshroom.core import desc from meshroom.core.graph import Graph from .utils import registerNodeDesc, unregisterNodeDesc class NodeWithKeyableAttributes(desc.Node): inputs = [ desc.BoolParam( name="keyableBool", label="Keyable Bool", description="A keyable bool parameter.", value=True, keyable=True, keyType="viewId" ), desc.IntParam( name="keyableInt", label="Keyable Integer", description="A keyable integer parameter.", value=5, range=(0, 100, 2), keyable=True, keyType="viewId" ), desc.FloatParam( name="keyableFloat", label="Keyable Float", description="A keyable float parameter.", value=5.5, range=(0.0, 100.0, 2.2), keyable=True, keyType="viewId" ), ] class TestKeyableAttribute: @classmethod def setup_class(cls): registerNodeDesc(NodeWithKeyableAttributes) @classmethod def teardown_class(cls): unregisterNodeDesc(NodeWithKeyableAttributes) def test_initialization(self): graph = Graph("") nodeA = graph.addNewNode(NodeWithKeyableAttributes.__name__) # Check attribute is keyable assert nodeA.keyableBool.keyable assert nodeA.keyableInt.keyable assert nodeA.keyableFloat.keyable # Check attribute key type assert nodeA.keyableBool.keyValues.keyType == "viewId" assert nodeA.keyableInt.keyValues.keyType == "viewId" assert nodeA.keyableFloat.keyValues.keyType == "viewId" # Check attribute pairs empty assert nodeA.keyableBool.isDefault assert nodeA.keyableInt.isDefault assert nodeA.keyableFloat.isDefault # Check attribute description value assert nodeA.keyableBool.desc.value == True assert nodeA.keyableInt.desc.value == 5 assert nodeA.keyableFloat.desc.value == 5.5 # Check attribute default value assert nodeA.keyableBool.getDefaultValue() == {} assert nodeA.keyableInt.getDefaultValue() == {} assert nodeA.keyableFloat.getDefaultValue() == {} # Check attribute serialized value assert nodeA.keyableBool.getSerializedValue() == {} assert nodeA.keyableInt.getSerializedValue() == {} assert nodeA.keyableFloat.getSerializedValue() == {} # Check attribute string value assert nodeA.keyableBool.getValueStr() == "{}" assert nodeA.keyableInt.getValueStr() == "{}" assert nodeA.keyableFloat.getValueStr() == "{}" def test_createReadUpdateDelete(self): graph = Graph("") nodeA = graph.addNewNode(NodeWithKeyableAttributes.__name__) # Check attribute value at key "0", should be default value assert nodeA.keyableBool.keyValues.getValueAtKeyOrDefault("0") == True assert nodeA.keyableInt.keyValues.getValueAtKeyOrDefault("0") == 5 assert nodeA.keyableFloat.keyValues.getValueAtKeyOrDefault("0") == 5.5 # Check attribute has key "0", should be False (no key) assert nodeA.keyableBool.keyValues.hasKey("0") == False assert nodeA.keyableInt.keyValues.hasKey("0") == False assert nodeA.keyableFloat.keyValues.hasKey("0") == False # Add attribute (key, value) at key "0" nodeA.keyableBool.keyValues.add("0", False) nodeA.keyableInt.keyValues.add("0", 10) nodeA.keyableFloat.keyValues.add("0", 10.1) # Check attribute value at key "0", should be the new value assert nodeA.keyableBool.keyValues.getValueAtKeyOrDefault("0") == False assert nodeA.keyableInt.keyValues.getValueAtKeyOrDefault("0") == 10 assert nodeA.keyableFloat.keyValues.getValueAtKeyOrDefault("0") == 10.1 # Check attribute has key "0", should be True (key exists) assert nodeA.keyableBool.keyValues.hasKey("0") == True assert nodeA.keyableInt.keyValues.hasKey("0") == True assert nodeA.keyableFloat.keyValues.hasKey("0") == True # Update attribute (key, value) at key "0" nodeA.keyableBool.keyValues.add("0", True) nodeA.keyableInt.keyValues.add("0", 20) nodeA.keyableFloat.keyValues.add("0", 20.2) # Check attribute value at key "0", should be the new updated value assert nodeA.keyableBool.keyValues.getValueAtKeyOrDefault("0") == True assert nodeA.keyableInt.keyValues.getValueAtKeyOrDefault("0") == 20 assert nodeA.keyableFloat.keyValues.getValueAtKeyOrDefault("0") == 20.2 # Check attribute has key "0", should be True (key exists) assert nodeA.keyableBool.keyValues.hasKey("0") == True assert nodeA.keyableInt.keyValues.hasKey("0") == True assert nodeA.keyableFloat.keyValues.hasKey("0") == True # Remove (key, value) at key "0" nodeA.keyableBool.keyValues.remove("0") nodeA.keyableInt.keyValues.remove("0") nodeA.keyableFloat.keyValues.remove("0") # Check attributes values at key "0", should be default value assert nodeA.keyableBool.keyValues.getValueAtKeyOrDefault("0") == True assert nodeA.keyableInt.keyValues.getValueAtKeyOrDefault("0") == 5 assert nodeA.keyableFloat.keyValues.getValueAtKeyOrDefault("0") == 5.5 # Check attribute has key "0", should be False (no key) assert nodeA.keyableBool.keyValues.hasKey("0") == False assert nodeA.keyableInt.keyValues.hasKey("0") == False assert nodeA.keyableFloat.keyValues.hasKey("0") == False def test_multipleKeys(self): graph = Graph("") nodeA = graph.addNewNode(NodeWithKeyableAttributes.__name__) # Add attribute (key, value) at key "0" nodeA.keyableBool.keyValues.add("0", False) nodeA.keyableInt.keyValues.add("0", 1) nodeA.keyableFloat.keyValues.add("0", 1.1) # Add attribute (key, value) at key "1" nodeA.keyableBool.keyValues.add("1", False) nodeA.keyableInt.keyValues.add("1", 2) nodeA.keyableFloat.keyValues.add("1", 2.2) # Add attribute (key, value) at key "2" nodeA.keyableBool.keyValues.add("2", True) nodeA.keyableInt.keyValues.add("2", 3) nodeA.keyableFloat.keyValues.add("2", 3.3) # Check attribute has key "0", should be True (key exists) assert nodeA.keyableBool.keyValues.hasKey("0") == True assert nodeA.keyableInt.keyValues.hasKey("0") == True assert nodeA.keyableFloat.keyValues.hasKey("0") == True # Check attribute has key "1", should be True (key exists) assert nodeA.keyableBool.keyValues.hasKey("1") == True assert nodeA.keyableInt.keyValues.hasKey("1") == True assert nodeA.keyableFloat.keyValues.hasKey("1") == True # Check attribute has key "2", should be True (key exists) assert nodeA.keyableBool.keyValues.hasKey("2") == True assert nodeA.keyableInt.keyValues.hasKey("2") == True assert nodeA.keyableFloat.keyValues.hasKey("2") == True # Check attributes values at key "0", should be default value assert nodeA.keyableBool.keyValues.getValueAtKeyOrDefault("0") == False assert nodeA.keyableInt.keyValues.getValueAtKeyOrDefault("0") == 1 assert nodeA.keyableFloat.keyValues.getValueAtKeyOrDefault("0") == 1.1 # Check attributes values at key "1", should be default value assert nodeA.keyableBool.keyValues.getValueAtKeyOrDefault("1") == False assert nodeA.keyableInt.keyValues.getValueAtKeyOrDefault("1") == 2 assert nodeA.keyableFloat.keyValues.getValueAtKeyOrDefault("1") == 2.2 # Check attributes values at key "2", should be default value assert nodeA.keyableBool.keyValues.getValueAtKeyOrDefault("2") == True assert nodeA.keyableInt.keyValues.getValueAtKeyOrDefault("2") == 3 assert nodeA.keyableFloat.keyValues.getValueAtKeyOrDefault("2") == 3.3 # Remove (key, value) at key "1" nodeA.keyableBool.keyValues.remove("1") nodeA.keyableInt.keyValues.remove("1") nodeA.keyableFloat.keyValues.remove("1") # Check attribute has key "1", should be False (no key) assert nodeA.keyableBool.keyValues.hasKey("1") == False assert nodeA.keyableInt.keyValues.hasKey("1") == False assert nodeA.keyableFloat.keyValues.hasKey("1") == False def test_linkAttribute(self): graph = Graph("") nodeA = graph.addNewNode(NodeWithKeyableAttributes.__name__) nodeB = graph.addNewNode(NodeWithKeyableAttributes.__name__) # Add some keys for nodeA.keyableInt nodeA.keyableInt.keyValues.add("0", 0) nodeA.keyableInt.keyValues.add("1", 1) nodeA.keyableInt.keyValues.add("2", 2) # Add link: # nodeB.keyableInt is a link for nodeA.keyableInt nodeA.keyableInt.connectTo(nodeB.keyableInt) # Check link assert nodeB.keyableInt.isLink == True assert nodeB.keyableInt.keyValues == nodeA.keyableInt.keyValues # Check existing (key, value) in nodeA.keyableInt and nodeB.keyableInt assert nodeA.keyableInt.keyValues.hasKey("1") == True assert nodeB.keyableInt.keyValues.hasKey("1") == True assert nodeA.keyableInt.keyValues.getValueAtKeyOrDefault("1") == 1 assert nodeB.keyableInt.keyValues.getValueAtKeyOrDefault("1") == 1 # Add a key to nodeB.keyableInt nodeB.keyableInt.keyValues.add("3", 3) # Check new (key, value) in nodeA.keyableInt and nodeB.keyableInt assert nodeA.keyableInt.keyValues.hasKey("3") == True assert nodeB.keyableInt.keyValues.hasKey("3") == True assert nodeA.keyableInt.keyValues.getValueAtKeyOrDefault("3") == 3 assert nodeB.keyableInt.keyValues.getValueAtKeyOrDefault("3") == 3 # Check nodeB.keyableInt serialized values assert nodeB.keyableInt.getSerializedValue() == nodeA.keyableInt.asLinkExpr() def test_uid(self): graph = Graph("") nodeA = graph.addNewNode(NodeWithKeyableAttributes.__name__) nodeB = graph.addNewNode(NodeWithKeyableAttributes.__name__) # Add some keys for nodeA.keyableInt nodeA.keyableInt.keyValues.add("0", 0) nodeA.keyableInt.keyValues.add("1", 1) nodeA.keyableInt.keyValues.add("2", 2) # Add the same keys for nodeB.keyableInt # But not in the same order nodeB.keyableInt.keyValues.add("2", 2) nodeB.keyableInt.keyValues.add("0", 0) nodeB.keyableInt.keyValues.add("1", 1) # Check UID, should be the same assert nodeA.keyableInt.uid() == nodeB.keyableInt.uid() # Remove (key, value) at key "1" from nodeA.keyableInt nodeA.keyableInt.keyValues.remove("1") # Check UID, should not be the same assert nodeA.keyableInt.uid() != nodeB.keyableInt.uid() ================================================ FILE: tests/test_attributeLambda.py ================================================ from meshroom.core import desc from meshroom.core.graph import Graph from .utils import registerNodeDesc, unregisterNodeDesc class NodeWithCallableValue(desc.Node): """Test node with callable default values to test executeValue.""" inputs = [ desc.IntParam( name="fixedInput", label="Fixed Input", description="A simple integer input.", value=10, range=(0, 100, 1), ), desc.IntParam( name="callableNodeInput", label="Callable Node Input", description="Input with a callable default that receives the node.", value=lambda node: node.fixedInput.value * 2, range=(0, 200, 1), ), desc.StringParam( name="callableAttrInput", label="Callable Attr Input (Compatibility)", description="Input with a callable default that receives the attribute for compatibility with the old behavior.", value=lambda attr: f"attr_{attr.name}", ), ] outputs = [ desc.File( name="output", label="Output", description="", value="{nodeCacheFolder}/output.txt", ) ] class TestExecuteValue: """Tests for the Attribute.executeValue method and callable value handling.""" @classmethod def setup_class(cls): registerNodeDesc(NodeWithCallableValue) @classmethod def teardown_class(cls): unregisterNodeDesc(NodeWithCallableValue) def test_executeValue_with_node_parameter(self): """executeValue should pass the node when the callable parameter is named 'node'.""" graph = Graph("") node = graph.addNewNode("NodeWithCallableValue") result = node.callableNodeInput.executeValue(lambda node: node.fixedInput.value + 5) assert result == 15 def test_executeValue_with_attr_parameter(self): """executeValue should pass the attribute when the parameter is not named 'node'.""" graph = Graph("") node = graph.addNewNode("NodeWithCallableValue") result = node.fixedInput.executeValue(lambda attr: attr.name) assert result == "fixedInput" def test_callable_default_value_with_node_param(self): """getDefaultValue should evaluate a callable descriptor value using node parameter.""" graph = Graph("") node = graph.addNewNode("NodeWithCallableValue") # The default value for callableNodeInput is lambda node: node.fixedInput.value * 2 default = node.callableNodeInput.getDefaultValue() assert default == 20 # 10 * 2 def test_callable_default_value_with_attr_param(self): """getDefaultValue should evaluate a callable descriptor value using attr parameter.""" graph = Graph("") node = graph.addNewNode("NodeWithCallableValue") # The default value for callableAttrInput is lambda attr: f"attr_{attr.name}" default = node.callableAttrInput.getDefaultValue() assert default == "attr_callableAttrInput" def test_set_value_with_callable(self): """Setting a callable value should evaluate it via executeValue.""" graph = Graph("") node = graph.addNewNode("NodeWithCallableValue") node.fixedInput.value = lambda node: 42 assert node.fixedInput.value == 42 def test_set_value_with_attr_callable(self): """Setting a callable value using attr parameter should evaluate correctly.""" graph = Graph("") node = graph.addNewNode("NodeWithCallableValue") node.fixedInput.value = lambda attr: 99 assert node.fixedInput.value == 99 def test_callable_default_reflects_current_state(self): """Callable default values should reflect the current node state when evaluated.""" graph = Graph("") node = graph.addNewNode("NodeWithCallableValue") # Change the fixedInput value node.fixedInput.value = 25 # Re-evaluate the callable default for callableNodeInput default = node.callableNodeInput.getDefaultValue() assert default == 50 # 25 * 2 def test_reset_to_default_with_callable(self): """resetToDefaultValue should correctly evaluate callable defaults.""" graph = Graph("") node = graph.addNewNode("NodeWithCallableValue") # Change value away from default node.callableAttrInput.value = "custom_value" assert node.callableAttrInput.value == "custom_value" # Reset should re-evaluate the callable node.callableAttrInput.resetToDefaultValue() assert node.callableAttrInput.value == "attr_callableAttrInput" ================================================ FILE: tests/test_attributeShape.py ================================================ from meshroom.core import desc from meshroom.core.graph import Graph from .utils import registerNodeDesc, unregisterNodeDesc class NodeWithShapeAttributes(desc.Node): inputs = [ desc.ShapeList( name="pointList", label="Point 2d List", description="Point 2d list.", shape=desc.Point2d( name="point", label="Point", description="A 2d point.", ), ), desc.ShapeList( name="keyablePointList", label="Keyable Point 2d List", description="Keyable point 2d list.", shape=desc.Point2d( name="point", label="Point", description="A 2d point.", keyable=True, keyType="viewId" ), ), desc.Point2d( name="point", label="Point 2d", description="A 2d point.", ), desc.Point2d( name="keyablePoint", label="Keyable Point 2d", description="A keyable 2d point.", keyable=True, keyType="viewId" ), desc.Line2d( name="line", label="Line 2d", description="A 2d line.", ), desc.Line2d( name="keyableLine", label="Keyable Line 2d", description="A keyable 2d line.", keyable=True, keyType="viewId" ), desc.Rectangle( name="rectangle", label="Rectangle", description="A rectangle.", ), desc.Rectangle( name="keyableRectangle", label="Keyable Rectangle", description="A keyable rectangle.", keyable=True, keyType="viewId" ), desc.Circle( name="circle", label="Circle", description="A circle.", ), desc.Circle( name="keyableCircle", label="Keyable Circle", description="A keyable circle.", keyable=True, keyType="viewId" ), ] class TestShapeAttribute: @classmethod def setup_class(cls): registerNodeDesc(NodeWithShapeAttributes) @classmethod def teardown_class(cls): unregisterNodeDesc(NodeWithShapeAttributes) def test_initialization(self): graph = Graph("") node = graph.addNewNode(NodeWithShapeAttributes.__name__) # ShapeListAttribute initialization # Check attribute has displayable shape (should be true) assert node.pointList.hasDisplayableShape assert node.keyablePointList.hasDisplayableShape # Check attribute type assert node.pointList.type == "ShapeList" assert node.keyablePointList.type == "ShapeList" # Check length # Should be 0, empty list assert len(node.pointList) == 0 assert len(node.keyablePointList) == 0 # ShapeAttribute initialization # Check attribute has displayable shape (should be true) assert node.point.hasDisplayableShape assert node.line.hasDisplayableShape assert node.rectangle.hasDisplayableShape assert node.circle.hasDisplayableShape assert node.keyablePoint.hasDisplayableShape assert node.keyableLine.hasDisplayableShape assert node.keyableRectangle.hasDisplayableShape assert node.keyableCircle.hasDisplayableShape # Check attribute type assert node.point.type == "Point2d" assert node.line.type == "Line2d" assert node.rectangle.type == "Rectangle" assert node.circle.type == "Circle" assert node.keyablePoint.type == "Point2d" assert node.keyableLine.type == "Line2d" assert node.keyableRectangle.type == "Rectangle" assert node.keyableCircle.type == "Circle" # Check attribute geometry number of observations # Should be 1 for static shape (default) assert node.point.geometry.nbObservations == 1 assert node.line.geometry.nbObservations == 1 assert node.rectangle.geometry.nbObservations == 1 assert node.circle.geometry.nbObservations == 1 # Should be 0 for keyable shape assert node.keyablePoint.geometry.nbObservations == 0 assert node.keyableLine.geometry.nbObservations == 0 assert node.keyableRectangle.geometry.nbObservations == 0 assert node.keyableCircle.geometry.nbObservations == 0 # Check shape attribute geometry observation keyable # Should be false for static shape assert not node.point.geometry.observationKeyable assert not node.line.geometry.observationKeyable assert not node.rectangle.geometry.observationKeyable assert not node.circle.geometry.observationKeyable # Should be true for keyable shape assert node.keyablePoint.geometry.observationKeyable assert node.keyableLine.geometry.observationKeyable assert node.keyableRectangle.geometry.observationKeyable assert node.keyableCircle.geometry.observationKeyable def test_staticShapeGeometry(self): graph = Graph("") node = graph.addNewNode(NodeWithShapeAttributes.__name__) observationPoint = {"x": 1, "y": 1} observationLine = {"a": {"x": 1, "y": 1}, "b": {"x": 2, "y": 2}} observationRectangle = {"center": {"x": 10, "y": 10}, "size": {"width": 20, "height": 20}} observationCircle = {"center": {"x": 10, "y": 10}, "radius": 20} # Check static shape has observation, should be true (default) assert node.point.geometry.hasObservation("0") assert node.line.geometry.hasObservation("0") assert node.rectangle.geometry.hasObservation("0") assert node.circle.geometry.hasObservation("0") # Check static shape get observation, should be default value assert node.point.geometry.getObservation("0") == node.point.geometry.getDefaultValue() assert node.line.geometry.getObservation("0") == node.line.geometry.getDefaultValue() assert node.rectangle.geometry.getObservation("0") == node.rectangle.geometry.getDefaultValue() assert node.circle.geometry.getObservation("0") == node.circle.geometry.getDefaultValue() # Create observation at key "0" # For static shape key has no effect node.point.geometry.setObservation("0", observationPoint) node.line.geometry.setObservation("0", observationLine) node.rectangle.geometry.setObservation("0", observationRectangle) node.circle.geometry.setObservation("0", observationCircle) # Check static shape has observation, should be true assert node.point.geometry.hasObservation("0") assert node.line.geometry.hasObservation("0") assert node.rectangle.geometry.hasObservation("0") assert node.circle.geometry.hasObservation("0") # Check static shape get observation, should be created observation assert node.point.geometry.getObservation("0") == observationPoint assert node.line.geometry.getObservation("0") == observationLine assert node.rectangle.geometry.getObservation("0") == observationRectangle assert node.circle.geometry.getObservation("0") == observationCircle # Update static shape observation node.point.geometry.setObservation("0", {"x": 2}) node.line.geometry.setObservation("0", {"a": {"x": 2, "y": 2}}) node.rectangle.geometry.setObservation("0", {"center": {"x": 20, "y": 20}}) node.circle.geometry.setObservation("0", {"radius": 40}) # Check static shape get observation, should be updated observation assert node.point.geometry.getObservation("0").get("x") == 2 assert node.line.geometry.getObservation("0").get("a") == {"x": 2, "y": 2} assert node.rectangle.geometry.getObservation("0").get("center") == {"x": 20, "y": 20} assert node.circle.geometry.getObservation("0").get("radius") == 40 # Reset static shape geometry node.point.geometry.resetToDefaultValue() node.line.geometry.resetToDefaultValue() node.rectangle.geometry.resetToDefaultValue() node.circle.geometry.resetToDefaultValue() # Check static shape get observation, should be default value assert node.point.geometry.getObservation("0") == node.point.geometry.getDefaultValue() assert node.line.geometry.getObservation("0") == node.line.geometry.getDefaultValue() assert node.rectangle.geometry.getObservation("0") == node.rectangle.geometry.getDefaultValue() assert node.circle.geometry.getObservation("0") == node.circle.geometry.getDefaultValue() def test_keyableShapeGeometry(self): graph = Graph("") node = graph.addNewNode(NodeWithShapeAttributes.__name__) observationPoint = {"x": 1, "y": 1} observationLine = {"a": {"x": 1, "y": 1}, "b": {"x": 2, "y": 2}} observationRectangle = {"center": {"x": 10, "y": 10}, "size": {"width": 20, "height": 20}} observationCircle = {"center": {"x": 10, "y": 10}, "radius": 20} # Check keyable shape has observation at key "0", should be false assert not node.keyablePoint.geometry.hasObservation("0") assert not node.keyableLine.geometry.hasObservation("0") assert not node.keyableRectangle.geometry.hasObservation("0") assert not node.keyableCircle.geometry.hasObservation("0") # Check keyable shape get observation at key "0", should be None (no observation) assert node.keyablePoint.geometry.getObservation("0") == None assert node.keyableLine.geometry.getObservation("0") == None assert node.keyableRectangle.geometry.getObservation("0") == None assert node.keyableCircle.geometry.getObservation("0") == None # Create observation at key "0" node.keyablePoint.geometry.setObservation("0", observationPoint) node.keyableLine.geometry.setObservation("0", observationLine) node.keyableRectangle.geometry.setObservation("0", observationRectangle) node.keyableCircle.geometry.setObservation("0", observationCircle) # Check keyable shape number of observations, should be 1 assert node.keyablePoint.geometry.nbObservations == 1 assert node.keyableLine.geometry.nbObservations == 1 assert node.keyableRectangle.geometry.nbObservations == 1 assert node.keyableCircle.geometry.nbObservations == 1 # Create observation at key "1" node.keyablePoint.geometry.setObservation("1", observationPoint) node.keyableLine.geometry.setObservation("1", observationLine) node.keyableRectangle.geometry.setObservation("1", observationRectangle) node.keyableCircle.geometry.setObservation("1", observationCircle) # Check keyable shape number of observations, should be 2 assert node.keyablePoint.geometry.nbObservations == 2 assert node.keyableLine.geometry.nbObservations == 2 assert node.keyableRectangle.geometry.nbObservations == 2 assert node.keyableCircle.geometry.nbObservations == 2 # Check keyable shape has observation, should be true assert node.keyablePoint.geometry.hasObservation("0") assert node.keyablePoint.geometry.hasObservation("1") assert node.keyableLine.geometry.hasObservation("0") assert node.keyableLine.geometry.hasObservation("1") assert node.keyableRectangle.geometry.hasObservation("0") assert node.keyableRectangle.geometry.hasObservation("1") assert node.keyableCircle.geometry.hasObservation("0") assert node.keyableCircle.geometry.hasObservation("1") # Check keyable shape get observation at key "0", should be created observation assert node.keyablePoint.geometry.getObservation("0") == observationPoint assert node.keyableLine.geometry.getObservation("0") == observationLine assert node.keyableRectangle.geometry.getObservation("0") == observationRectangle assert node.keyableCircle.geometry.getObservation("0") == observationCircle # Update keyable shape observation at key "1" node.keyablePoint.geometry.setObservation("1", {"x": 2}) node.keyableLine.geometry.setObservation("1", {"a": {"x": 2, "y": 2}}) node.keyableRectangle.geometry.setObservation("1", {"center": {"x": 20, "y": 20}}) node.keyableCircle.geometry.setObservation("1", {"radius": 40}) # Check keyable shape get observation at key "1", should be updated observation assert node.keyablePoint.geometry.getObservation("1").get("x") == 2 assert node.keyableLine.geometry.getObservation("1").get("a") == {"x": 2, "y": 2} assert node.keyableRectangle.geometry.getObservation("1").get("center") == {"x": 20, "y": 20} assert node.keyableCircle.geometry.getObservation("1").get("radius") == 40 # Remove keyable shape observation at key "0" node.keyablePoint.geometry.removeObservation("0") node.keyableLine.geometry.removeObservation("0") node.keyableRectangle.geometry.removeObservation("0") node.keyableCircle.geometry.removeObservation("0") # Check keyable shape has observation at key "0", should be false assert not node.keyablePoint.geometry.hasObservation("0") assert not node.keyableLine.geometry.hasObservation("0") assert not node.keyableRectangle.geometry.hasObservation("0") assert not node.keyableCircle.geometry.hasObservation("0") # Reset keyable shape geometry node.keyablePoint.geometry.resetToDefaultValue() node.keyableLine.geometry.resetToDefaultValue() node.keyableRectangle.geometry.resetToDefaultValue() node.keyableCircle.geometry.resetToDefaultValue() # Check keyable shape has observation at key "1", should be false assert not node.keyablePoint.geometry.hasObservation("0") assert not node.keyableLine.geometry.hasObservation("0") assert not node.keyableRectangle.geometry.hasObservation("0") assert not node.keyableCircle.geometry.hasObservation("0") # Check keyable shape number of observations, should be 0 assert node.keyablePoint.geometry.nbObservations == 0 assert node.keyableLine.geometry.nbObservations == 0 assert node.keyableRectangle.geometry.nbObservations == 0 assert node.keyableCircle.geometry.nbObservations == 0 def test_shapeList(self): graph = Graph("") node = graph.addNewNode(NodeWithShapeAttributes.__name__) pointValue = {"userName": "testPoint", "userColor": "#fff", "geometry": {"x": 1, "y": 1}} keyablePointValue = {"userName": "testKeyablePoint", "userColor": "#fff", "geometry": {}} # Check visibility assert node.pointList.isVisible assert node.keyablePointList.isVisible # Check number of shapes, should be 0 (no shape) assert len(node.pointList) == 0 assert len(node.keyablePointList) == 0 # Add 3 elements node.pointList.append(pointValue) node.pointList.append(pointValue) node.pointList.append(pointValue) node.keyablePointList.append(keyablePointValue) node.keyablePointList.append(keyablePointValue) node.keyablePointList.append(keyablePointValue) # Check number of shapes, should be 3 assert len(node.pointList) == 3 assert len(node.keyablePointList) == 3 # Check attribute second element assert node.pointList.at(1).geometry.getValueAsDict() == pointValue.get("geometry") assert node.keyablePointList.at(1).geometry.getValueAsDict() == keyablePointValue.get("geometry") # Change visibility node.pointList.isVisible = False node.keyablePointList.isVisible = False # Check shapes visibility assert not node.pointList.at(0).isVisible assert not node.pointList.at(1).isVisible assert not node.pointList.at(2).isVisible assert not node.keyablePointList.at(0).isVisible assert not node.keyablePointList.at(1).isVisible assert not node.keyablePointList.at(2).isVisible # Reset shape lists node.pointList.resetToDefaultValue() node.keyablePointList.resetToDefaultValue() # Check number of shapes, should be 0 (no shape) assert len(node.pointList) == 0 assert len(node.keyablePointList) == 0 def test_linkAttribute(self): graph = Graph("") nodeA = graph.addNewNode(NodeWithShapeAttributes.__name__) nodeB = graph.addNewNode(NodeWithShapeAttributes.__name__) pointGeometryValue = {"x": 1, "y": 1} pointValue = {"userName": "testPoint", "userColor": "#fff", "geometry": pointGeometryValue} # Add link: # nodeB.pointList is a link for nodeA.pointList nodeA.pointList.connectTo(nodeB.pointList) # nodeB.point is a link for nodeA.point nodeA.point.connectTo(nodeB.point) # Check link assert nodeB.pointList.isLink == True assert nodeB.pointList.inputLink == nodeA.pointList assert nodeB.point.isLink == True assert nodeB.point.inputLink == nodeA.point # Set observation for nodeA.point nodeA.point.geometry.setObservation("0", pointGeometryValue) # Add 3 shape to nodeA.pointList nodeA.pointList.append(pointValue) nodeA.pointList.append(pointValue) nodeA.pointList.append(pointValue) # Check nodeB.point geometry assert nodeB.point.geometry.getObservation(0) == pointGeometryValue # Check nodeB.pointList geometry assert len(nodeB.pointList) == 3 assert nodeB.pointList.at(0).geometry.getValueAsDict() == pointGeometryValue assert nodeB.pointList.at(1).geometry.getValueAsDict() == pointGeometryValue assert nodeB.pointList.at(2).geometry.getValueAsDict() == pointGeometryValue # Update nodeA.point and nodeA.pointList[1] geometry nodeA.point.geometry.setObservation("0", {"x": 2}) nodeA.pointList.at(1).geometry.setObservation("0", {"x": 2}) # Check nodeB second shape geometry assert nodeB.point.geometry.getObservation("0").get("x") == 2 assert nodeB.pointList.at(1).geometry.getObservation("0").get("x") == 2 # Check serialized value assert nodeB.point.getSerializedValue() == nodeA.point.asLinkExpr() assert nodeB.pointList.getSerializedValue() == nodeA.pointList.asLinkExpr() def test_exportDict(self): graph = Graph("") node = graph.addNewNode(NodeWithShapeAttributes.__name__) observationPoint = {"x": 1, "y": 1} observationLine = {"a": {"x": 1, "y": 1}, "b": {"x": 2, "y": 2}} observationRectangle = {"center": {"x": 10, "y": 10}, "size": {"width": 20, "height": 20}} observationCircle = {"center": {"x": 10, "y": 10}, "radius": 20} pointValue = {"userName": "testPoint", "userColor": "#fff", "geometry": observationPoint} keyablePointGeometryValue = {"x": {"0": observationPoint.get("x")}, "y": {"0": observationPoint.get("y")}} keyablePointValue = {"userName": "testKeyablePoint", "userColor": "#fff", "geometry": keyablePointGeometryValue} # Check uninitialized shape attribute # Shape list attribute should be empty list assert node.pointList.getGeometriesAsDict() == [] assert node.keyablePointList.getGeometriesAsDict() == [] assert node.pointList.getShapesAsDict() == [] assert node.keyablePointList.getShapesAsDict() == [] # Static shape attribute should be default assert node.point.geometry.getValueAsDict() == {"x": -1, "y": -1} assert node.line.geometry.getValueAsDict() == {"a": {"x": -1, "y": -1}, "b": {"x": -1, "y": -1}} assert node.rectangle.geometry.getValueAsDict() == {"center": {"x": -1, "y": -1}, "size": {"width": -1, "height": -1}} assert node.circle.geometry.getValueAsDict() == {"center": {"x": -1, "y": -1}, "radius": -1} assert node.point.getShapeAsDict() == {"name": node.point.rootName, "type": node.point.type, "properties": {"color": node.point.userColor, "x": -1, "y": -1}} assert node.line.getShapeAsDict() == {"name": node.line.rootName, "type": node.line.type, "properties": {"color": node.line.userColor, "a": {"x": -1, "y": -1}, "b": {"x": -1, "y": -1}}} assert node.rectangle.getShapeAsDict() == {"name": node.rectangle.rootName, "type": node.rectangle.type, "properties": {"color": node.rectangle.userColor, "center": {"x": -1, "y": -1}, "size": {"width": -1, "height": -1}}} assert node.circle.getShapeAsDict() == {"name": node.circle.rootName, "type": node.circle.type, "properties": {"color": node.circle.userColor, "center": {"x": -1, "y": -1}, "radius": -1}} # Keyable shape attribute should be empty dict assert node.keyablePoint.geometry.getValueAsDict() == {} assert node.keyableLine.geometry.getValueAsDict() == {} assert node.keyableRectangle.geometry.getValueAsDict() == {} assert node.keyableCircle.geometry.getValueAsDict() == {} assert node.keyablePoint.getShapeAsDict() == {"name": node.keyablePoint.rootName, "type": node.keyablePoint.type, "properties": {"color": node.keyablePoint.userColor}, "observations": {}} assert node.keyableLine.getShapeAsDict() == {"name": node.keyableLine.rootName, "type": node.keyableLine.type, "properties": {"color": node.keyableLine.userColor}, "observations": {}} assert node.keyableRectangle.getShapeAsDict() == {"name": node.keyableRectangle.rootName, "type": node.keyableRectangle.type, "properties": {"color": node.keyableRectangle.userColor}, "observations": {}} assert node.keyableCircle.getShapeAsDict() == {"name": node.keyableCircle.rootName, "type": node.keyableCircle.type, "properties": {"color": node.keyableCircle.userColor}, "observations": {}} # Add one shape with an observation node.pointList.append(pointValue) node.keyablePointList.append(keyablePointValue) # Add one observation node.point.geometry.setObservation("0", observationPoint) node.keyablePoint.geometry.setObservation("0", observationPoint) node.line.geometry.setObservation("0", observationLine) node.keyableLine.geometry.setObservation("0", observationLine) node.rectangle.geometry.setObservation("0", observationRectangle) node.keyableRectangle.geometry.setObservation("0", observationRectangle) node.circle.geometry.setObservation("0", observationCircle) node.keyableCircle.geometry.setObservation("0", observationCircle) # Check shape attribute # Shape list attribute should be empty dict assert node.pointList.getGeometriesAsDict() == [observationPoint] assert node.keyablePointList.getGeometriesAsDict() == [{"0": observationPoint}] assert node.pointList.getShapesAsDict()[0].get("properties") == {"color": pointValue.get("userColor")} | observationPoint assert node.keyablePointList.getShapesAsDict()[0].get("observations") == {"0": observationPoint} # Not keyable shape attribute should be default assert node.point.geometry.getValueAsDict() == observationPoint assert node.line.geometry.getValueAsDict() == observationLine assert node.rectangle.geometry.getValueAsDict() == observationRectangle assert node.circle.geometry.getValueAsDict() == observationCircle assert node.point.getShapeAsDict().get("properties") == {"color": node.point.userColor} | observationPoint assert node.line.getShapeAsDict().get("properties") == {"color": node.line.userColor} | observationLine assert node.rectangle.getShapeAsDict().get("properties") == {"color": node.rectangle.userColor} | observationRectangle assert node.circle.getShapeAsDict().get("properties") == {"color": node.circle.userColor} | observationCircle # Keyable shape attribute should be empty dict assert node.keyablePoint.geometry.getValueAsDict() == {"0": observationPoint} assert node.keyableLine.geometry.getValueAsDict() == {"0": observationLine} assert node.keyableRectangle.geometry.getValueAsDict() == {"0": observationRectangle} assert node.keyableCircle.geometry.getValueAsDict() == {"0": observationCircle} assert node.keyablePoint.getShapeAsDict().get("observations") == {"0": observationPoint} assert node.keyableLine.getShapeAsDict().get("observations") == {"0": observationLine} assert node.keyableRectangle.getShapeAsDict().get("observations") == {"0": observationRectangle} assert node.keyableCircle.getShapeAsDict().get("observations") == {"0": observationCircle} ================================================ FILE: tests/test_attributes.py ================================================ from meshroom.core.graph import Graph import pytest import logging logger = logging.getLogger('test') valid3DExtensionFiles = [(f'test.{ext}', True) for ext in ('obj', 'stl', 'fbx', 'gltf', 'abc', 'ply')] invalid3DExtensionFiles = [(f'test.{ext}', False) for ext in ('', 'exe', 'jpg', 'png', 'py')] valid2DSemantics= [(semantic, True) for semantic in ('image', 'imageList', 'sequence')] invalid2DSemantics = [(semantic, False) for semantic in ('3d', '', 'multiline', 'color/hue')] validTextExtensionFiles = [(f'test{ext}', True) for ext in ('.txt', '.json', '.log', '.csv', '.md')] invalidTextExtensionFiles = [(f'test{ext}', False) for ext in ('', '.exe', '.jpg', '.obj', '.py')] def test_attribute_retrieve_linked_input_and_output_attributes(): """ Check that an attribute can retrieve the linked input and output attributes """ # n0 -- n1 -- n2 # \ \ # ---------- n3 g = Graph('') n0 = g.addNewNode('Ls', input='') n1 = g.addNewNode('Ls', input=n0.output) n2 = g.addNewNode('Ls', input=n1.output) n3 = g.addNewNode('AppendFiles', input=n1.output, input2=n2.output) # check that the attribute can retrieve its linked input attributes assert n0.output.hasAnyOutputLinks assert not n3.output.hasAnyOutputLinks assert len(n0.input.allInputLinks) == 0 assert len(n1.input.allInputLinks) == 1 assert n1.input.allInputLinks[0] == n0.output assert len(n1.output.allOutputLinks) == 2 assert n1.output.allOutputLinks[0] == n2.input assert n1.output.allOutputLinks[1] == n3.input n0.graph = None # Bounding cases assert not n0.output.hasAnyOutputLinks assert len(n0.input.allInputLinks) == 0 assert len(n0.output.allOutputLinks) == 0 @pytest.mark.parametrize("givenFile,expected", valid3DExtensionFiles + invalid3DExtensionFiles) def test_attribute_is3D_file_extensions(givenFile, expected): """ Check what makes an attribute a valid 3d media """ g = Graph('') n0 = g.addNewNode('Ls', input='') # Given assert not n0.input.is3dDisplayable # When n0.input.value = givenFile # Then assert n0.input.is3dDisplayable == expected def test_attribute_i3D_by_description_semantic(): """ """ # Given g = Graph('') n0 = g.addNewNode('Ls', input='') assert not n0.output.is3dDisplayable # When n0.output.desc._semantic = "3d" # Then assert n0.output.is3dDisplayable @pytest.mark.parametrize("givenSemantic,expected", valid2DSemantics + invalid2DSemantics) def test_attribute_is2D_file_semantic(givenSemantic, expected): """ Check what makes an attribute a valid 2d media """ g = Graph('') n0 = g.addNewNode('Ls', input='') # Given n0.input.desc._semantic = "" assert not n0.input.is2dDisplayable # When n0.input.desc._semantic = givenSemantic # Then assert n0.input.is2dDisplayable == expected @pytest.mark.parametrize("givenFile,expected", validTextExtensionFiles + invalidTextExtensionFiles) def test_attribute_isText_file_extensions(givenFile, expected): """ Check what makes an attribute a valid text file """ g = Graph('') n0 = g.addNewNode('Ls', input='') # Given assert not n0.input.isTextDisplayable # When n0.input.value = givenFile # Then assert n0.input.isTextDisplayable == expected def test_attribute_isText_by_description_semantic(): """ Check that an attribute with semantic 'textFile' is considered a text file """ # Given g = Graph('') n0 = g.addNewNode('Ls', input='') # The input attribute has an empty default value, so it is not text displayable assert not n0.input.isTextDisplayable # When n0.input.desc._semantic = "textFile" # Then assert n0.input.isTextDisplayable ================================================ FILE: tests/test_compatibility.py ================================================ #!/usr/bin/env python # coding:utf-8 import tempfile import os import copy from typing import Type import pytest from meshroom.core import desc, pluginManager from meshroom.core.plugins import NodePlugin from meshroom.core.exception import GraphCompatibilityError, NodeUpgradeError from meshroom.core.graph import Graph, loadGraph from meshroom.core.node import CompatibilityNode, CompatibilityIssue, Node from .utils import registeredNodeTypes, overrideNodeTypeVersion, registerNodeDesc, unregisterNodeDesc SampleGroupV1 = [ desc.IntParam(name="a", label="a", description="", value=0, range=None), desc.ListAttribute( name="b", elementDesc=desc.FloatParam(name="p", label="", description="", value=0.0, range=None), label="b", description="", ) ] SampleGroupV2 = [ desc.IntParam(name="a", label="a", description="", value=0, range=None), desc.ListAttribute( name="b", elementDesc=desc.GroupAttribute(name="p", label="", description="", items=SampleGroupV1), label="b", description="", ) ] # SampleGroupV3 is SampleGroupV2 with one more int parameter SampleGroupV3 = [ desc.IntParam(name="a", label="a", description="", value=0, range=None), desc.IntParam(name="notInSampleGroupV2", label="notInSampleGroupV2", description="", value=0, range=None), desc.ListAttribute( name="b", elementDesc=desc.GroupAttribute(name="p", label="", description="", items=SampleGroupV1), label="b", description="", ) ] class SampleNodeV1(desc.Node): """ Version 1 Sample Node """ inputs = [ desc.File(name="input", label="Input", description="", value=""), desc.StringParam(name="paramA", label="ParamA", description="", value="", invalidate=False) # No impact on UID ] outputs = [ desc.File(name="output", label="Output", description="", value="{nodeCacheFolder}") ] class SampleNodeV2(desc.Node): """ Changes from V1: * 'input' has been renamed to 'in' """ inputs = [ desc.File(name="in", label="Input", description="", value=""), desc.StringParam(name="paramA", label="ParamA", description="", value="", invalidate=False), # No impact on UID ] outputs = [ desc.File(name="output", label="Output", description="", value="{nodeCacheFolder}") ] class SampleNodeV3(desc.Node): """ Changes from V3: * 'paramA' has been removed' """ inputs = [ desc.File(name="in", label="Input", description="", value=""), ] outputs = [ desc.File(name="output", label="Output", description="", value="{nodeCacheFolder}") ] class SampleNodeV4(desc.Node): """ Changes from V3: * 'paramA' has been added """ inputs = [ desc.File(name="in", label="Input", description="", value=""), desc.ListAttribute(name="paramA", label="ParamA", elementDesc=desc.GroupAttribute( items=SampleGroupV1, name="gA", label="gA", description=""), description="") ] outputs = [ desc.File(name="output", label="Output", description="", value="{nodeCacheFolder}") ] class SampleNodeV5(desc.Node): """ Changes from V4: * 'paramA' elementDesc has changed from SampleGroupV1 to SampleGroupV2 """ inputs = [ desc.File(name="in", label="Input", description="", value=""), desc.ListAttribute(name="paramA", label="ParamA", elementDesc=desc.GroupAttribute( items=SampleGroupV2, name="gA", label="gA", description=""), description="") ] outputs = [ desc.File(name="output", label="Output", description="", value="{nodeCacheFolder}") ] class SampleNodeV6(desc.Node): """ Changes from V5: * 'paramA' elementDesc has changed from SampleGroupV2 to SampleGroupV3 """ inputs = [ desc.File(name="in", label="Input", description="", value=""), desc.ListAttribute(name="paramA", label="ParamA", elementDesc=desc.GroupAttribute( items=SampleGroupV3, name="gA", label="gA", description=""), description="") ] outputs = [ desc.File(name="output", label="Output", description="", value="{nodeCacheFolder}") ] class SampleInputNodeV1(desc.InputNode): """ Version 1 Sample Input Node """ inputs = [ desc.StringParam(name="path", label="Path", description="", value="", invalidate=False) # No impact on UID ] outputs = [ desc.File(name="output", label="Output", description="", value="{nodeCacheFolder}") ] class SampleInputNodeV2(desc.InputNode): """ Changes from V1: * 'path' has been renamed to 'in' """ inputs = [ desc.StringParam(name="in", label="path", description="", value="", invalidate=False) # No impact on UID ] outputs = [ desc.File(name="output", label="Output", description="", value="{nodeCacheFolder}") ] def replaceNodeTypeDesc(nodeType: str, nodeDesc: Type[desc.Node]): """Change the `nodeDesc` associated to `nodeType`.""" pluginManager.getRegisteredNodePlugins()[nodeType] = NodePlugin(nodeDesc) def test_unknown_node_type(): """ Test compatibility behavior for unknown node type. """ registerNodeDesc(SampleNodeV1) g = Graph("") n = g.addNewNode("SampleNodeV1", input="/dev/null", paramA="foo") graphFile = os.path.join(tempfile.mkdtemp(), "test_unknown_node_type.mg") g.save(graphFile) internalFolder = n.internalFolder nodeName = n.name unregisterNodeDesc(SampleNodeV1) # Reload file g = loadGraph(graphFile) os.remove(graphFile) assert len(g.nodes) == 1 n = g.node(nodeName) # SampleNodeV1 is now an unknown type # Check node instance type and compatibility issue type assert isinstance(n, CompatibilityNode) assert n.issue == CompatibilityIssue.UnknownNodeType # Check if attributes are properly restored assert len(n.attributes) == 3 assert n.input.isInput assert n.output.isOutput # Check if internal folder assert n.internalFolder == internalFolder # Upgrade cannot be performed on unknown node types assert not n.canUpgrade with pytest.raises(NodeUpgradeError): g.upgradeNode(nodeName) def test_description_conflict(): """ Test compatibility behavior for conflicting node descriptions. """ # Copy registered node types to be able to restore them originalNodeTypes = copy.deepcopy(pluginManager.getRegisteredNodePlugins()) nodeTypes = [SampleNodeV1, SampleNodeV2, SampleNodeV3, SampleNodeV4, SampleNodeV5] nodes = [] g = Graph("") # Register and instantiate instances of all node types except last one for nt in nodeTypes[:-1]: registerNodeDesc(nt) n = g.addNewNode(nt.__name__) if nt == SampleNodeV4: # Initialize list attribute with values to create a conflict with V5 n.paramA.value = [{'a': 0, 'b': [1.0, 2.0]}] nodes.append(n) graphFile = os.path.join(tempfile.mkdtemp(), "test_description_conflict.mg") g.save(graphFile) # Reload file as-is, ensure no compatibility issue is detected (no CompatibilityNode instances) loadGraph(graphFile, strictCompatibility=True) # Offset node types register to create description conflicts # Each node type name now reference the next one's implementation for i, nt in enumerate(nodeTypes[:-1]): pluginManager.getRegisteredNodePlugins()[nt.__name__] = NodePlugin(nodeTypes[i + 1]) # Reload file g = loadGraph(graphFile) os.remove(graphFile) assert len(g.nodes) == len(nodes) for srcNode in nodes: nodeName = srcNode.name compatNode = g.node(srcNode.name) # Node description clashes between what has been saved assert isinstance(compatNode, CompatibilityNode) assert srcNode.internalFolder == compatNode.internalFolder # Case by case description conflict verification if isinstance(srcNode.nodeDesc, SampleNodeV1): # V1 => V2: 'input' has been renamed to 'in' assert len(compatNode.attributes) == 3 assert list(compatNode.attributes.keys()) == ["input", "paramA", "output"] assert hasattr(compatNode, "input") assert not hasattr(compatNode, "in") # Perform upgrade upgradedNode = g.upgradeNode(nodeName) assert isinstance(upgradedNode, Node) and \ isinstance(upgradedNode.nodeDesc, SampleNodeV2) assert list(upgradedNode.attributes.keys()) == ["in", "paramA", "output"] assert not hasattr(upgradedNode, "input") assert hasattr(upgradedNode, "in") # Check UID has changed (not the same set of attributes) assert upgradedNode.internalFolder != srcNode.internalFolder elif isinstance(srcNode.nodeDesc, SampleNodeV2): # V2 => V3: 'paramA' has been removed assert len(compatNode.attributes) == 3 assert hasattr(compatNode, "paramA") # Perform upgrade upgradedNode = g.upgradeNode(nodeName) assert isinstance(upgradedNode, Node) and \ isinstance(upgradedNode.nodeDesc, SampleNodeV3) assert not hasattr(upgradedNode, "paramA") # Check UID is identical (paramA not part of UID) assert upgradedNode.internalFolder == srcNode.internalFolder elif isinstance(srcNode.nodeDesc, SampleNodeV3): # V3 => V4: 'paramA' has been added assert len(compatNode.attributes) == 2 assert not hasattr(compatNode, "paramA") # Perform upgrade upgradedNode = g.upgradeNode(nodeName) assert isinstance(upgradedNode, Node) and \ isinstance(upgradedNode.nodeDesc, SampleNodeV4) assert hasattr(upgradedNode, "paramA") assert isinstance(upgradedNode.paramA.desc, desc.ListAttribute) # paramA child attributes invalidate UID assert upgradedNode.internalFolder != srcNode.internalFolder elif isinstance(srcNode.nodeDesc, SampleNodeV4): # V4 => V5: 'paramA' elementDesc has changed from SampleGroupV1 to SampleGroupV2 assert len(compatNode.attributes) == 3 assert hasattr(compatNode, "paramA") groupAttribute = compatNode.paramA.desc.elementDesc assert isinstance(groupAttribute, desc.GroupAttribute) # Check that Compatibility node respect SampleGroupV1 description for elt in groupAttribute.items: assert isinstance(elt, next(a for a in SampleGroupV1 if a.name == elt.name).__class__) # Perform upgrade upgradedNode = g.upgradeNode(nodeName) assert isinstance(upgradedNode, Node) and \ isinstance(upgradedNode.nodeDesc, SampleNodeV5) assert hasattr(upgradedNode, "paramA") # Parameter was incompatible, value could not be restored assert upgradedNode.paramA.isDefault assert upgradedNode.internalFolder != srcNode.internalFolder else: raise ValueError("Unexpected node type: " + srcNode.nodeType) # Restore original node types pluginManager._nodePlugins = originalNodeTypes def test_upgradeAllNodes(): registerNodeDesc(SampleNodeV1) registerNodeDesc(SampleNodeV2) registerNodeDesc(SampleInputNodeV1) registerNodeDesc(SampleInputNodeV2) g = Graph("") n1 = g.addNewNode("SampleNodeV1") n2 = g.addNewNode("SampleNodeV2") n3 = g.addNewNode("SampleInputNodeV1") n4 = g.addNewNode("SampleInputNodeV2") n1Name = n1.name n2Name = n2.name n3Name = n3.name n4Name = n4.name graphFile = os.path.join(tempfile.mkdtemp(), "test_description_conflict.mg") g.save(graphFile) # Replace SampleNodeV1 by SampleNodeV2 and SampleInputNodeV1 by SampleInputNodeV2 pluginManager.getRegisteredNodePlugins()[SampleNodeV1.__name__] = \ pluginManager.getRegisteredNodePlugin(SampleNodeV2.__name__) pluginManager.getRegisteredNodePlugins()[SampleInputNodeV1.__name__] = \ pluginManager.getRegisteredNodePlugin(SampleInputNodeV2.__name__) # Make SampleNodeV2 and SampleInputNodeV2 an unknown type unregisterNodeDesc(SampleNodeV2) unregisterNodeDesc(SampleInputNodeV2) # Reload file g = loadGraph(graphFile) os.remove(graphFile) # Both nodes are CompatibilityNodes assert len(g.compatibilityNodes) == 3 assert g.node(n1Name).canUpgrade # description conflict assert not g.node(n2Name).canUpgrade # unknown type assert not g.node(n4Name).canUpgrade # unknown type # Input node with a description conflict and no invalidating attribute: the upgrade can be done automatically assert not g.node(n3Name).isCompatibilityNode # Upgrade all upgradable nodes g.upgradeAllNodes() # Only the nodes with an unknown type have not been upgraded assert len(g.compatibilityNodes) == 2 assert n2Name in g.compatibilityNodes.keys() assert n4Name in g.compatibilityNodes.keys() unregisterNodeDesc(SampleNodeV1) unregisterNodeDesc(SampleInputNodeV1) def test_conformUpgrade(): registerNodeDesc(SampleNodeV5) registerNodeDesc(SampleNodeV6) g = Graph("") n1 = g.addNewNode("SampleNodeV5") n1.paramA.value = [{"a": 0, "b": [{"a": 0, "b": [1.0, 2.0]}, {"a": 1, "b": [1.0, 2.0]}]}] n1Name = n1.name graphFile = os.path.join(tempfile.mkdtemp(), "test_conform_upgrade.mg") g.save(graphFile) # Replace SampleNodeV5 by SampleNodeV6 pluginManager.getRegisteredNodePlugins()[SampleNodeV5.__name__] = \ pluginManager.getRegisteredNodePlugin(SampleNodeV6.__name__) # Reload file g = loadGraph(graphFile) os.remove(graphFile) # Node is a CompatibilityNode assert len(g.compatibilityNodes) == 1 assert g.node(n1Name).canUpgrade # Upgrade all upgradable nodes g.upgradeAllNodes() # Only the node with an unknown type has not been upgraded assert len(g.compatibilityNodes) == 0 upgradedNode = g.node(n1Name) # Check upgrade assert isinstance(upgradedNode, Node) and isinstance(upgradedNode.nodeDesc, SampleNodeV6) # Check conformation assert len(upgradedNode.paramA.value) == 1 unregisterNodeDesc(SampleNodeV5) unregisterNodeDesc(SampleNodeV6) class TestGraphLoadingWithStrictCompatibility: def test_failsOnUnknownNodeType(self, graphSavedOnDisk): with registeredNodeTypes([SampleNodeV1]): graph: Graph = graphSavedOnDisk graph.addNewNode(SampleNodeV1.__name__) graph.save() with pytest.raises(GraphCompatibilityError): loadGraph(graph.filepath, strictCompatibility=True) def test_failsOnNodeDescriptionCompatibilityIssue(self, graphSavedOnDisk): with registeredNodeTypes([SampleNodeV1, SampleNodeV2]): graph: Graph = graphSavedOnDisk graph.addNewNode(SampleNodeV1.__name__) graph.save() replaceNodeTypeDesc(SampleNodeV1.__name__, SampleNodeV2) with pytest.raises(GraphCompatibilityError): loadGraph(graph.filepath, strictCompatibility=True) class TestGraphTemplateLoading: def test_failsOnUnknownNodeTypeError(self, graphSavedOnDisk): with registeredNodeTypes([SampleNodeV1, SampleNodeV2]): graph: Graph = graphSavedOnDisk graph.addNewNode(SampleNodeV1.__name__) graph.save(template=True) with pytest.raises(GraphCompatibilityError): loadGraph(graph.filepath, strictCompatibility=True) def test_loadsIfIncompatibleNodeHasDefaultAttributeValues(self, graphSavedOnDisk): with registeredNodeTypes([SampleNodeV1, SampleNodeV2]): graph: Graph = graphSavedOnDisk graph.addNewNode(SampleNodeV1.__name__) graph.save(template=True) replaceNodeTypeDesc(SampleNodeV1.__name__, SampleNodeV2) loadGraph(graph.filepath, strictCompatibility=True) def test_loadsIfValueSetOnCompatibleAttribute(self, graphSavedOnDisk): with registeredNodeTypes([SampleNodeV1, SampleNodeV2]): graph: Graph = graphSavedOnDisk node = graph.addNewNode(SampleNodeV1.__name__, paramA="foo") graph.save(template=True) replaceNodeTypeDesc(SampleNodeV1.__name__, SampleNodeV2) loadedGraph = loadGraph(graph.filepath, strictCompatibility=True) assert loadedGraph.nodes.get(node.name).paramA.value == "foo" def test_loadsIfValueSetOnIncompatibleAttribute(self, graphSavedOnDisk): with registeredNodeTypes([SampleNodeV1, SampleNodeV2]): graph: Graph = graphSavedOnDisk graph.addNewNode(SampleNodeV1.__name__, input="foo") graph.save(template=True) replaceNodeTypeDesc(SampleNodeV1.__name__, SampleNodeV2) loadGraph(graph.filepath, strictCompatibility=True) class TestVersionConflict: def test_loadingConflictingNodeVersionCreatesCompatibilityNodes(self, graphSavedOnDisk): graph: Graph = graphSavedOnDisk with registeredNodeTypes([SampleNodeV1]): with overrideNodeTypeVersion(SampleNodeV1, "1.0"): node = graph.addNewNode(SampleNodeV1.__name__) graph.save() with overrideNodeTypeVersion(SampleNodeV1, "2.0"): otherGraph = Graph("") otherGraph.load(graph.filepath) assert len(otherGraph.compatibilityNodes) == 1 assert otherGraph.node(node.name).issue is CompatibilityIssue.VersionConflict def test_loadingUnspecifiedNodeVersionAssumesCurrentVersion(self, graphSavedOnDisk): graph: Graph = graphSavedOnDisk with registeredNodeTypes([SampleNodeV1]): graph.addNewNode(SampleNodeV1.__name__) graph.save() with overrideNodeTypeVersion(SampleNodeV1, "2.0"): otherGraph = Graph("") otherGraph.load(graph.filepath) assert len(otherGraph.compatibilityNodes) == 0 class UidTestingNodeV1(desc.Node): inputs = [ desc.File(name="input", label="Input", description="", value="", invalidate=True), ] outputs = [desc.File(name="output", label="Output", description="", value="{nodeCacheFolder}")] class UidTestingNodeV2(desc.Node): """ Changes from SampleNodeBV1: * 'param' has been added """ inputs = [ desc.File(name="input", label="Input", description="", value="", invalidate=True), desc.ListAttribute( name="param", label="Param", elementDesc=desc.File( name="file", label="File", description="", value="", ), description="", ), ] outputs = [desc.File(name="output", label="Output", description="", value="{nodeCacheFolder}")] class UidTestingNodeV3(desc.Node): """ Changes from SampleNodeBV2: * 'input' is not invalidating the UID. """ inputs = [ desc.File(name="input", label="Input", description="", value="", invalidate=False), desc.ListAttribute( name="param", label="Param", elementDesc=desc.File( name="file", label="File", description="", value="", ), description="", ), ] outputs = [desc.File(name="output", label="Output", description="", value="{nodeCacheFolder}")] class TestUidConflict: def test_changingInvalidateOnAttributeDescCreatesUidConflict(self, graphSavedOnDisk): with registeredNodeTypes([UidTestingNodeV2]): graph: Graph = graphSavedOnDisk node = graph.addNewNode(UidTestingNodeV2.__name__) graph.save() replaceNodeTypeDesc(UidTestingNodeV2.__name__, UidTestingNodeV3) with pytest.raises(GraphCompatibilityError): loadGraph(graph.filepath, strictCompatibility=True) loadedGraph = loadGraph(graph.filepath) loadedNode = loadedGraph.node(node.name) assert isinstance(loadedNode, CompatibilityNode) assert loadedNode.issue == CompatibilityIssue.UidConflict def test_uidConflictingNodesPreserveConnectionsOnGraphLoad(self, graphSavedOnDisk): with registeredNodeTypes([UidTestingNodeV2]): graph: Graph = graphSavedOnDisk nodeA = graph.addNewNode(UidTestingNodeV2.__name__) nodeB = graph.addNewNode(UidTestingNodeV2.__name__) nodeB.param.append("") nodeA.output.connectTo(nodeB.param.at(0)) graph.save() replaceNodeTypeDesc(UidTestingNodeV2.__name__, UidTestingNodeV3) loadedGraph = loadGraph(graph.filepath) assert len(loadedGraph.compatibilityNodes) == 2 loadedNodeA = loadedGraph.node(nodeA.name) loadedNodeB = loadedGraph.node(nodeB.name) assert loadedNodeB.param.at(0).inputLink == loadedNodeA.output def test_upgradingConflictingNodesPreserveConnections(self, graphSavedOnDisk): with registeredNodeTypes([UidTestingNodeV2]): graph: Graph = graphSavedOnDisk nodeA = graph.addNewNode(UidTestingNodeV2.__name__) nodeB = graph.addNewNode(UidTestingNodeV2.__name__) # Double-connect nodeA.output to nodeB, on both a single attribute and a list attribute nodeB.param.append("") nodeA.output.connectTo(nodeB.param.at(0)) nodeA.output.connectTo(nodeB.input) graph.save() replaceNodeTypeDesc(UidTestingNodeV2.__name__, UidTestingNodeV3) def checkNodeAConnectionsToNodeB(): loadedNodeA = loadedGraph.node(nodeA.name) loadedNodeB = loadedGraph.node(nodeB.name) return ( loadedNodeB.param.at(0).inputLink == loadedNodeA.output and loadedNodeB.input.inputLink == loadedNodeA.output ) loadedGraph = loadGraph(graph.filepath) loadedGraph.upgradeNode(nodeA.name) assert checkNodeAConnectionsToNodeB() loadedGraph.upgradeNode(nodeB.name) assert checkNodeAConnectionsToNodeB() assert len(loadedGraph.compatibilityNodes) == 0 def test_uidConflictDoesNotPropagateToValidDownstreamNodeThroughConnection( self, graphSavedOnDisk): with registeredNodeTypes([UidTestingNodeV1, UidTestingNodeV2]): graph: Graph = graphSavedOnDisk nodeA = graph.addNewNode(UidTestingNodeV2.__name__) nodeB = graph.addNewNode(UidTestingNodeV1.__name__) nodeA.output.connectTo(nodeB.input) graph.save() replaceNodeTypeDesc(UidTestingNodeV2.__name__, UidTestingNodeV3) loadedGraph = loadGraph(graph.filepath) assert len(loadedGraph.compatibilityNodes) == 1 def test_uidConflictDoesNotPropagateToValidDownstreamNodeThroughListConnection( self, graphSavedOnDisk): with registeredNodeTypes([UidTestingNodeV2, UidTestingNodeV3]): graph: Graph = graphSavedOnDisk nodeA = graph.addNewNode(UidTestingNodeV2.__name__) nodeB = graph.addNewNode(UidTestingNodeV3.__name__) nodeB.param.append("") nodeA.output.connectTo(nodeB.param.at(0)) graph.save() replaceNodeTypeDesc(UidTestingNodeV2.__name__, UidTestingNodeV3) loadedGraph = loadGraph(graph.filepath) assert len(loadedGraph.compatibilityNodes) == 1 ================================================ FILE: tests/test_compute.py ================================================ # coding:utf-8 """ In this test we test the code that is usually launched directly from the meshroom_compute script TODO : We could directly test by launching the executable (`desc.node._MESHROOM_COMPUTE_EXE`) """ import os import re from pathlib import Path import logging from meshroom.core.graph import Graph, loadGraph from meshroom.core import desc, pluginManager, loadClassesNodes from meshroom.core.node import Status from meshroom.core.plugins import Plugin from .utils import registerNodeDesc, unregisterNodeDesc LOGGER = logging.getLogger("TestCompute") def executeChunks(node, size): os.makedirs(node.internalFolder) logFiles = {} for chunkIndex in range(size): iteration = chunkIndex if size > 1 else -1 logFileName = f"{chunkIndex}.log" logFile = Path(node.internalFolder) / logFileName logFiles[chunkIndex] = logFile logFile.touch() node.prepareLogger(iteration) node.preprocess() if size > 1: chunk = node.chunks[chunkIndex] chunk.process(True, True) else: node.process(True, True) node.postprocess() node.restoreLogger() return logFiles _INPUTS = [ desc.IntParam( name="input", label="Input", description="input", value=0, ), ] _OUTPUTS = [ desc.IntParam( name="output", label="Output", description="Output", value=None, ), ] class TestNodeA(desc.BaseNode): """ Test process with chunks """ __test__ = False _size = 2 size = desc.StaticNodeSize(2) parallelization = desc.Parallelization(blockSize=1) inputs = _INPUTS outputs = _OUTPUTS def processChunk(self, chunk): chunk.logManager.start("info") iteration = chunk.range.iteration nbBlocks = chunk.range.nbBlocks chunk.logger.info(f"> (chunk.logger) {chunk.node.name}") LOGGER.info(f"> (root logger) {iteration}/{nbBlocks}") chunk.logManager.end() class TestNodeB(TestNodeA): """ Test process with 1 chunk but still implementing processChunk """ __test__ = False _size = 1 size = desc.StaticNodeSize(1) parallelization = None class TestNodeC(desc.BaseNode): """ Test process without chunks and without processChunk """ __test__ = False size = desc.StaticNodeSize(1) parallelization = None inputs = _INPUTS outputs = _OUTPUTS def process(self, node): LOGGER.info(f"> {node.name}") class TestNodeLogger: """ Test that the logger is correctly set up during the different stages of the compute and that logs are correctly written in the log file. """ logPrefix = r"\[\d{2}:\d{2}:\d{2}\.\d{3}\]\[info\] > " @classmethod def setup_class(cls): registerNodeDesc(TestNodeA) registerNodeDesc(TestNodeB) registerNodeDesc(TestNodeC) @classmethod def teardown_class(cls): unregisterNodeDesc(TestNodeA) unregisterNodeDesc(TestNodeB) unregisterNodeDesc(TestNodeC) def test_processChunks(self, tmp_path): graph = Graph("") graph._cacheDir = tmp_path # TestNodeA : multiple chunks node = graph.addNewNode(TestNodeA.__name__) # Compute logFiles = executeChunks(node, 2) for chunkIndex, logFile in logFiles.items(): with open(logFile, "r") as f: content = f.read() reg = re.compile(self.logPrefix + r"\(chunk.logger\) TestNodeA_1") assert len(reg.findall(content)) == 1 reg = re.compile(self.logPrefix + r"\(root logger\) " + f"{chunkIndex}/2") assert len(reg.findall(content)) == 1 # TestNodeA : single chunk nodeB = graph.addNewNode(TestNodeB.__name__) logFiles = executeChunks(nodeB, 1) for chunkIndex, logFile in logFiles.items(): with open(logFile, "r") as f: content = f.read() reg = re.compile(self.logPrefix + r"\(chunk.logger\) TestNodeB_1") assert len(reg.findall(content)) == 1 reg = re.compile(self.logPrefix + r"\(root logger\) 0/0") assert len(reg.findall(content)) == 1 def test_process(self, tmp_path): graph = Graph("") graph._cacheDir = tmp_path node = graph.addNewNode(TestNodeC.__name__) # Compute logFiles = executeChunks(node, 1) for _, logFile in logFiles.items(): with open(logFile, "r") as f: content = f.read() reg = re.compile(self.logPrefix + "TestNodeC_1") assert len(reg.findall(content)) == 1 class TestLockUpdates: """ Tests for node locking behaviour during status transitions. Nodes should be properly locked when they undergo computation statuses and unlocked when their status is reset (through parameter changes, for example). """ plugin = None @classmethod def setup_class(cls): folder = os.path.join(os.path.dirname(__file__), "plugins", "meshroom") package = "pluginA" cls.plugin = Plugin(package, folder) nodes = loadClassesNodes(folder, package) for node in nodes: cls.plugin.addNodePlugin(node) pluginManager.addPlugin(cls.plugin) @classmethod def teardown_class(cls): for node in cls.plugin.nodes.values(): pluginManager.unregisterNode(node) pluginManager.removePlugin(cls.plugin) cls.plugin = None @staticmethod def checkNodeStatusAndLock(node, expectedStatus, expectedLock): assert node.globalStatus == expectedStatus.name assert node.locked == expectedLock def test_lockDuringComputation(self, graphSavedOnDisk): """ Test that a node is properly locked during the execution of its "process()" method and unlocked once the process is finished. Both the global status and the lock status should be updated throughout the process. """ import threading import time graph: Graph = graphSavedOnDisk node = graph.addNewNode("PluginANodeA") graph.save() self.checkNodeStatusAndLock(node, Status.NONE, False) # PluginANodeA will sleep 3 seconds in its "process", so we can check the status and lock during the process execution thread = threading.Thread(target=node.process, kwargs={"inCurrentEnv": True}) thread.start() time.sleep(0.5) # Wait for the process to start and update the status self.checkNodeStatusAndLock(node, Status.RUNNING, True) # Wait for the process to finish and update the status thread.join() self.checkNodeStatusAndLock(node, Status.SUCCESS, False) def test_lockResetOnParameterChange(self, graphSavedOnDisk): """ Test that a node's lock is properly reset when its status is reset, for example through parameter changes. """ graph: Graph = graphSavedOnDisk node = graph.addNewNode("PluginANodeA") graph.save() self.checkNodeStatusAndLock(node, Status.NONE, False) node.process(inCurrentEnv=True) self.checkNodeStatusAndLock(node, Status.SUCCESS, False) # Change a parameter to reset the status and check that the lock is also reset node.input.value = "path" self.checkNodeStatusAndLock(node, Status.NONE, False) def test_lockResetOnDuplicatedParameterChange(self, graphSavedOnDisk): """ Test that when a node is duplicated while running, the duplicate node is independent from the original one and that changing a parameter on the duplicate node resets its status and lock without impacting the original node's status and lock. """ import threading import time graph: Graph = graphSavedOnDisk node = graph.addNewNode("PluginANodeA") graph.save() self.checkNodeStatusAndLock(node, Status.NONE, False) thread = threading.Thread(target=node.process, kwargs={"inCurrentEnv": True}) thread.start() time.sleep(0.5) self.checkNodeStatusAndLock(node, Status.RUNNING, True) # Duplicate the running & locked node duplicate = graph.duplicateNodes([node]) # "duplicate" is an ordered_dict with the original node as key and a list of duplicates as value. # We know there is only one duplicate in this test. assert len(duplicate) == 1 duplicate = list(duplicate.values())[0][0] # Check the duplicate node is valid assert duplicate is not None assert duplicate.nodeType == node.nodeType assert duplicate.name != node.name # Check the status of the duplicate node is RUNNING but that it is not locked: # it has not been computed and should be independent from the original node self.checkNodeStatusAndLock(duplicate, Status.RUNNING, False) # Change a parameter to reset the duplicatenode's status and check that the lock is also reset duplicate.input.value = "path" self.checkNodeStatusAndLock(duplicate, Status.NONE, False) self.checkNodeStatusAndLock(node, Status.RUNNING, True) thread.join() self.checkNodeStatusAndLock(node, Status.SUCCESS, False) self.checkNodeStatusAndLock(duplicate, Status.NONE, False) def test_noLockResetOnGraphLoad(self, graphSavedOnDisk): """ Test that when a graph is loaded while a node is running, the node's status and lock are not reset and that the node is still locked. """ import threading import time graph: Graph = graphSavedOnDisk node = graph.addNewNode("PluginANodeA") graph.save() self.checkNodeStatusAndLock(node, Status.NONE, False) thread = threading.Thread(target=node.process, kwargs={"inCurrentEnv": True}) thread.start() time.sleep(0.5) self.checkNodeStatusAndLock(node, Status.RUNNING, True) # Load the graph while the node is running and check that the node's status and lock are not reset loadedGraph = loadGraph(graph.filepath) loadedNode = loadedGraph.node(node.name) self.checkNodeStatusAndLock(loadedNode, Status.RUNNING, True) thread.join() # Make sure the status is up-to-date loadedNode.updateStatusFromCache() self.checkNodeStatusAndLock(loadedNode, Status.SUCCESS, False) def test_noDownstreamNodeLockDuringComputation(self, graphSavedOnDisk): """ Test that when a node is running, its downstream nodes are not locked, and their status is not updated. """ import threading import time graph: Graph = graphSavedOnDisk node = graph.addNewNode("PluginANodeA") downstreamNode = graph.addNewNode("PluginANodeB") node.output.connectTo(downstreamNode.input) graph.save() self.checkNodeStatusAndLock(node, Status.NONE, False) self.checkNodeStatusAndLock(downstreamNode, Status.NONE, False) thread = threading.Thread(target=node.process, kwargs={"inCurrentEnv": True}) thread.start() time.sleep(0.5) self.checkNodeStatusAndLock(node, Status.RUNNING, True) self.checkNodeStatusAndLock(downstreamNode, Status.NONE, False) thread.join() self.checkNodeStatusAndLock(node, Status.SUCCESS, False) self.checkNodeStatusAndLock(downstreamNode, Status.NONE, False) def test_upstreamLockDuringComputation(self, graphSavedOnDisk): """ Test that when a node is running, its upstream nodes are locked and their status remains unchanged. """ import threading import time graph: Graph = graphSavedOnDisk node = graph.addNewNode("PluginANodeA") downstreamNode = graph.addNewNode("PluginANodeB") node.output.connectTo(downstreamNode.input) graph.save() self.checkNodeStatusAndLock(node, Status.NONE, False) self.checkNodeStatusAndLock(downstreamNode, Status.NONE, False) node.process(inCurrentEnv=True) self.checkNodeStatusAndLock(node, Status.SUCCESS, False) self.checkNodeStatusAndLock(downstreamNode, Status.NONE, False) thread = threading.Thread(target=downstreamNode.process, kwargs={"inCurrentEnv": True}) thread.start() time.sleep(0.5) self.checkNodeStatusAndLock(node, Status.SUCCESS, True) self.checkNodeStatusAndLock(downstreamNode, Status.RUNNING, True) thread.join() self.checkNodeStatusAndLock(node, Status.SUCCESS, False) self.checkNodeStatusAndLock(downstreamNode, Status.SUCCESS, False) def test_noDownstreamLockAfterParameterChange(self, graphSavedOnDisk): """ Test that when a computed node's parameter is updated, the downstream node's status and lock are updated accordingly. """ graph: Graph = graphSavedOnDisk node = graph.addNewNode("PluginANodeA") downstreamNode = graph.addNewNode("PluginANodeB") node.output.connectTo(downstreamNode.input) graph.save() self.checkNodeStatusAndLock(node, Status.NONE, False) self.checkNodeStatusAndLock(downstreamNode, Status.NONE, False) node.process(inCurrentEnv=True) downstreamNode.process(inCurrentEnv=True) self.checkNodeStatusAndLock(node, Status.SUCCESS, False) self.checkNodeStatusAndLock(downstreamNode, Status.SUCCESS, False) # Change a parameter on the upstream node and check that the downstream node's status is reset but not locked node.input.value = "path" self.checkNodeStatusAndLock(node, Status.NONE, False) self.checkNodeStatusAndLock(downstreamNode, Status.NONE, False) def test_noUpstreamLockAfterParameterChange(self, graphSavedOnDisk): """ Test that when a computed node's parameter is updated, the upstream node's status and lock are not impacted. """ graph: Graph = graphSavedOnDisk node = graph.addNewNode("PluginANodeA") downstreamNode = graph.addNewNode("PluginANodeB") node.output.connectTo(downstreamNode.input) graph.save() self.checkNodeStatusAndLock(node, Status.NONE, False) self.checkNodeStatusAndLock(downstreamNode, Status.NONE, False) node.process(inCurrentEnv=True) downstreamNode.process(inCurrentEnv=True) self.checkNodeStatusAndLock(node, Status.SUCCESS, False) self.checkNodeStatusAndLock(downstreamNode, Status.SUCCESS, False) # Disconnect the downstream node and check that the upstream node's status is not reset and that it is not locked downstreamNode.input.disconnectEdge() self.checkNodeStatusAndLock(node, Status.SUCCESS, False) self.checkNodeStatusAndLock(downstreamNode, Status.NONE, False) class TestNode_SizeA(desc.BaseNode): __test__ = False size = desc.DynamicNodeSize("nbChunks") parallelization = desc.Parallelization(blockSize=1) inputs = [ desc.IntParam( name="nbChunks", label="nbChunks", description="number of chunks", value=2, ), desc.File( name="nodeInput", label="Node Input", description="", value="", ), ] outputs = [ desc.File( name='output', label='Output', description='Output', value=os.path.join("{nodeCacheFolder}"), commandLineGroup='', ), ] def processChunk(self, chunk): pass class TestNode_SizeB(TestNode_SizeA): """ Inherit the linked node size but not parallelized """ size = desc.DynamicNodeSize("nodeInput") parallelization = False class TestNode_SizeC(TestNode_SizeA): """ Inherit the linked node size and parallelized """ size = desc.DynamicNodeSize("nodeInput") parallelization = desc.Parallelization(blockSize=1) class TestSizeUpdate: plugin = None @classmethod def setup_class(cls): registerNodeDesc(TestNode_SizeA) registerNodeDesc(TestNode_SizeB) registerNodeDesc(TestNode_SizeC) @classmethod def teardown_class(cls): unregisterNodeDesc(TestNode_SizeA) unregisterNodeDesc(TestNode_SizeB) unregisterNodeDesc(TestNode_SizeC) @staticmethod def checkNodeSizeAndStatus(node, nodeSize, nbChunks, status): assert node.size == nodeSize assert len(node._chunks) == nbChunks assert node.globalStatus == status.name def test_correctSizeUpdate(self, graphSavedOnDisk): graph: Graph = graphSavedOnDisk nodeA = graph.addNewNode("TestNode_SizeA") nodeB = graph.addNewNode("TestNode_SizeB") nodeA.output.connectTo(nodeB.nodeInput) nodeC = graph.addNewNode("TestNode_SizeC") nodeB.output.connectTo(nodeC.nodeInput) graph.save() # A self.checkNodeSizeAndStatus(nodeA, 0, 0, Status.NONE) nodeA.createChunks() nodeA.process(inCurrentEnv=True) self.checkNodeSizeAndStatus(nodeA, 2, 2, Status.SUCCESS) # B self.checkNodeSizeAndStatus(nodeB, 0, 1, Status.NONE) nodeB.createChunks() nodeB._updateNodeSize() nodeB.process(inCurrentEnv=True) self.checkNodeSizeAndStatus(nodeB, 2, 1, Status.SUCCESS) # C self.checkNodeSizeAndStatus(nodeC, 0, 0, Status.NONE) nodeC.createChunks() nodeC.process(inCurrentEnv=True) self.checkNodeSizeAndStatus(nodeC, 2, 2, Status.SUCCESS) ================================================ FILE: tests/test_graph.py ================================================ from meshroom.core.exception import CyclicDependencyError from meshroom.core.graph import Graph import pytest def test_depth(): graph = Graph("Tests tasks depth") tA = graph.addNewNode("Ls", input="/tmp") tB = graph.addNewNode("AppendText", inputText="echo B") tC = graph.addNewNode("AppendText", inputText="echo C") tA.output.connectTo(tB.input) tB.output.connectTo(tC.input) assert tA.depth == 0 assert tB.depth == 1 assert tC.depth == 2 def test_depth_diamond_graph(): graph = Graph("Tests tasks depth") tA = graph.addNewNode("Ls", input="/tmp") tB = graph.addNewNode("AppendText", inputText="echo B") tC = graph.addNewNode("AppendText", inputText="echo C") tD = graph.addNewNode("AppendFiles") tA.output.connectTo(tB.input) tA.output.connectTo(tC.input) tB.output.connectTo(tD.input) tC.output.connectTo(tD.input2) assert tA.depth == 0 assert tB.depth == 1 assert tC.depth == 1 assert tD.depth == 2 nodes, edges = graph.dfsOnFinish() assert len(nodes) == 4 assert nodes[0] == tA assert nodes[-1] == tD assert len(edges) == 4 nodes, edges = graph.dfsOnFinish(startNodes=[tD]) assert len(nodes) == 4 assert nodes[0] == tA assert nodes[-1] == tD assert len(edges) == 4 nodes, edges = graph.dfsOnFinish(startNodes=[tB]) assert len(nodes) == 2 assert nodes[0] == tA assert nodes[-1] == tB assert len(edges) == 1 def test_depth_diamond_graph2(): graph = Graph("Tests tasks depth") tA = graph.addNewNode("Ls", input="/tmp") tB = graph.addNewNode("AppendText", inputText="echo B") tC = graph.addNewNode("AppendText", inputText="echo C") tD = graph.addNewNode("AppendText", inputText="echo D") tE = graph.addNewNode("AppendFiles") # C # / \ # /---/---->\ # A -> B ---> E # \ / # \ / # D tA.output.connectTo(tB.input) tB.output.connectTo(tC.input) tB.output.connectTo(tD.input) tA.output.connectTo(tE.input) tB.output.connectTo(tE.input2) tC.output.connectTo(tE.input3) tD.output.connectTo(tE.input4) assert tA.depth == 0 assert tB.depth == 1 assert tC.depth == 2 assert tD.depth == 2 assert tE.depth == 3 nodes, edges = graph.dfsOnFinish() assert len(nodes) == 5 assert nodes[0] == tA assert nodes[-1] == tE assert len(edges) == 7 nodes, edges = graph.dfsOnFinish(startNodes=[tE]) assert len(nodes) == 5 assert nodes[0] == tA assert nodes[-1] == tE assert len(edges) == 7 nodes, edges = graph.dfsOnFinish(startNodes=[tD]) assert len(nodes) == 3 assert nodes[0] == tA assert nodes[1] == tB assert nodes[2] == tD assert len(edges) == 2 nodes, edges = graph.dfsOnFinish(startNodes=[tB]) assert len(nodes) == 2 assert nodes[0] == tA assert nodes[-1] == tB assert len(edges) == 1 def test_transitive_reduction(): graph = Graph("Tests tasks depth") tA = graph.addNewNode("Ls", input="/tmp") tB = graph.addNewNode("AppendText", inputText="echo B") tC = graph.addNewNode("AppendText", inputText="echo C") tD = graph.addNewNode("AppendText", inputText="echo D") tE = graph.addNewNode("AppendFiles") # C # / \ # /---/---->\ # A -> B ---> E # \ / # \ / # D tA.output.connectTo(tE.input) tA.output.connectTo(tB.input) tB.output.connectTo(tC.input) tB.output.connectTo(tD.input) tB.output.connectTo(tE.input4) tC.output.connectTo(tE.input3) tD.output.connectTo(tE.input2) flowEdges = graph.flowEdges() flowEdgesRes = [(tB, tA), (tD, tB), (tC, tB), (tE, tD), (tE, tC), ] assert set(flowEdgesRes) == set(flowEdges) assert len(graph._nodesMinMaxDepths) == len(graph.nodes) for node, (_, maxDepth) in graph._nodesMinMaxDepths.items(): assert node.depth == maxDepth def test_graph_reverse_dfsOnDiscover(): graph = Graph("Test dfsOnDiscover(reverse=True)") # ------------\ # / ~ C - E - F # A - B # ~ D A = graph.addNewNode("Ls", input="/tmp") B = graph.addNewNode("AppendText", inputText=A.output) C = graph.addNewNode("AppendText", inputText=B.output) D = graph.addNewNode("AppendText", inputText=B.output) E = graph.addNewNode("Ls", input=C.output) F = graph.addNewNode("AppendText", input=A.output, inputText=E.output) # Get all nodes from A (use set, order not guaranteed) nodes = graph.dfsOnDiscover(startNodes=[A], reverse=True)[0] assert set(nodes) == {A, B, D, C, E, F} # Get all nodes from B nodes = graph.dfsOnDiscover(startNodes=[B], reverse=True)[0] assert set(nodes) == {B, D, C, E, F} # Get all nodes of type AppendText from B nodes = graph.dfsOnDiscover(startNodes=[B], filterTypes=["AppendText"], reverse=True)[0] assert set(nodes) == {B, D, C, F} # Get all nodes from C (order guaranteed) nodes = graph.dfsOnDiscover(startNodes=[C], reverse=True)[0] assert nodes == [C, E, F] # Get all nodes nodes = graph.dfsOnDiscover(reverse=True)[0] assert set(nodes) == {A, B, C, D, E, F} def test_graph_dfsOnDiscover(): graph = Graph("Test dfsOnDiscover(reverse=False)") # ------------\ # / ~ C - E - F # A - B # ~ D # G G = graph.addNewNode("Ls", input="/tmp") A = graph.addNewNode("Ls", input="/tmp") B = graph.addNewNode("AppendText", inputText=A.output) C = graph.addNewNode("AppendText", inputText=B.output) D = graph.addNewNode("AppendText", input=G.output, inputText=B.output) E = graph.addNewNode("Ls", input=C.output) F = graph.addNewNode("AppendText", input=A.output, inputText=E.output) # Get all nodes from A (use set, order not guaranteed) nodes = graph.dfsOnDiscover(startNodes=[A], reverse=False)[0] assert set(nodes) == {A} # Get all nodes from D nodes = graph.dfsOnDiscover(startNodes=[D], reverse=False)[0] assert set(nodes) == {A, B, D, G} # Get all nodes from E nodes = graph.dfsOnDiscover(startNodes=[E], reverse=False)[0] assert set(nodes) == {A, B, C, E} # Get all nodes from F nodes = graph.dfsOnDiscover(startNodes=[F], reverse=False)[0] assert set(nodes) == {A, B, C, E, F} # Get all nodes of type AppendText from C nodes = graph.dfsOnDiscover(startNodes=[C], filterTypes=["AppendText"], reverse=False)[0] assert set(nodes) == {B, C} # Get all nodes from D (order guaranteed) nodes = graph.dfsOnDiscover(startNodes=[D], longestPathFirst=True, reverse=False)[0] assert nodes == [D, B, A, G] # Get all nodes nodes = graph.dfsOnDiscover(reverse=False)[0] assert set(nodes) == {A, B, C, D, E, F, G} def test_graph_nodes_sorting(): graph = Graph("") ls0 = graph.addNewNode("Ls") ls1 = graph.addNewNode("Ls") ls2 = graph.addNewNode("Ls") assert graph.nodesOfType("Ls", sortedByIndex=True) == [ls0, ls1, ls2] graph = Graph("") # 'Random' creation order (what happens when loading a file) ls2 = graph.addNewNode("Ls", name="Ls_2") ls0 = graph.addNewNode("Ls", name="Ls_0") ls1 = graph.addNewNode("Ls", name="Ls_1") assert graph.nodesOfType("Ls", sortedByIndex=True) == [ls0, ls1, ls2] def test_duplicate_nodes(): """ Test nodes duplication. """ # n0 -- n1 -- n2 # \ \ # ---------- n3 g = Graph("") n0 = g.addNewNode("Ls", input="/tmp") n1 = g.addNewNode("Ls", input=n0.output) n2 = g.addNewNode("Ls", input=n1.output) n3 = g.addNewNode("AppendFiles", input=n1.output, input2=n2.output) # Duplicate from n1 nodes_to_duplicate, _ = g.dfsOnDiscover(startNodes=[n1], reverse=True, dependenciesOnly=True) nMap = g.duplicateNodes(srcNodes=nodes_to_duplicate) for s, duplicated in nMap.items(): for d in duplicated: assert s.nodeType == d.nodeType # Check number of duplicated nodes and that every parent node has been duplicated once assert len(nMap) == 3 and \ all([len(nMap[i]) == 1 for i in nMap.keys()]) # Check connections # Access directly index 0 because we know there is a single duplicate for each parent node assert nMap[n1][0].input.inputLink == n0.output assert nMap[n2][0].input.inputLink == nMap[n1][0].output assert nMap[n3][0].input.inputLink == nMap[n1][0].output assert nMap[n3][0].input2.inputLink == nMap[n2][0].output def test_rename_nodes(): """ Test renaming nodes. """ graph = Graph("") ls0 = graph.addNewNode("Ls") ls1 = graph.addNewNode("Ls") ls2 = graph.addNewNode("Ls") # Test with empty string assert ls0.name == "Ls_1" graph.renameNode(ls0, "") assert ls0.name == "Ls_1" # Rename graph.renameNode(ls0, "nodels") assert ls0.name == "nodels" graph.renameNode(ls1, "nodels") assert ls1.name == "nodels_1" graph.renameNode(ls2, "nodels") assert ls2.name == "nodels_2" # Check we cannot rename in locked mode ls0.setLocked(True) graph.renameNode(ls0, "lockedLs") assert ls0.name == "nodels" ================================================ FILE: tests/test_graphIO.py ================================================ import json import os from textwrap import dedent from pathlib import Path from meshroom.core import desc from meshroom.core.graph import Graph from meshroom.core.node import CompatibilityIssue from .utils import registeredNodeTypes, overrideNodeTypeVersion class SimpleNode(desc.Node): inputs = [ desc.File(name="input", label="Input", description="", value=""), ] outputs = [ desc.File(name="output", label="Output", description="", value=""), ] class NodeWithListAttributes(desc.Node): inputs = [ desc.ListAttribute( name="listInput", label="List Input", description="", elementDesc=desc.File(name="file", label="File", description="", value=""), exposed=True, ), desc.GroupAttribute( name="group", label="Group", description="", items=[ desc.ListAttribute( name="listInput", label="List Input", description="", elementDesc=desc.File(name="file", label="File", description="", value=""), exposed=True, ), ], ), ] def assertPathsAreEqual(pathA, pathB): return Path(pathA).resolve().as_posix() == Path(pathB).resolve().as_posix() def compareGraphsContent(graphA: Graph, graphB: Graph) -> bool: """Returns whether the content (node and deges) of two graphs are considered identical. Similar nodes: nodes with the same name, type and compatibility status. Similar edges: edges with the same source and destination attribute names. """ def _buildNodesSet(graph: Graph): return set([(node.name, node.nodeType, node.isCompatibilityNode) for node in graph.nodes]) def _buildEdgesSet(graph: Graph): return set([(edge.src.rootName, edge.dst.rootName) for edge in graph.edges]) nodesSetA, edgesSetA = _buildNodesSet(graphA), _buildEdgesSet(graphA) nodesSetB, edgesSetB = _buildNodesSet(graphB), _buildEdgesSet(graphB) return nodesSetA == nodesSetB and edgesSetA == edgesSetB class TestImportGraphContent: def test_importEmptyGraph(self): graph = Graph("") otherGraph = Graph("") nodes = otherGraph.importGraphContent(graph) assert len(nodes) == 0 assert len(graph.nodes) == 0 def test_importGraphWithSingleNode(self): graph = Graph("") with registeredNodeTypes([SimpleNode]): graph.addNewNode(SimpleNode.__name__) otherGraph = Graph("") otherGraph.importGraphContent(graph) assert compareGraphsContent(graph, otherGraph) def test_importGraphWithSeveralNodes(self): graph = Graph("") with registeredNodeTypes([SimpleNode]): graph.addNewNode(SimpleNode.__name__) graph.addNewNode(SimpleNode.__name__) otherGraph = Graph("") otherGraph.importGraphContent(graph) assert compareGraphsContent(graph, otherGraph) def test_importingGraphWithNodesAndEdges(self): graph = Graph("") with registeredNodeTypes([SimpleNode]): nodeA_1 = graph.addNewNode(SimpleNode.__name__) nodeA_2 = graph.addNewNode(SimpleNode.__name__) nodeA_1.output.connectTo(nodeA_2.input) otherGraph = Graph("") otherGraph.importGraphContent(graph) assert compareGraphsContent(graph, otherGraph) def test_edgeRemappingOnImportingGraphSeveralTimes(self): graph = Graph("") with registeredNodeTypes([SimpleNode]): nodeA_1 = graph.addNewNode(SimpleNode.__name__) nodeA_2 = graph.addNewNode(SimpleNode.__name__) nodeA_1.output.connectTo(nodeA_2.input) otherGraph = Graph("") otherGraph.importGraphContent(graph) otherGraph.importGraphContent(graph) def test_edgeRemappingOnImportingGraphWithUnkownNodeTypesSeveralTimes(self): graph = Graph("") with registeredNodeTypes([SimpleNode]): nodeA_1 = graph.addNewNode(SimpleNode.__name__) nodeA_2 = graph.addNewNode(SimpleNode.__name__) nodeA_1.output.connectTo(nodeA_2.input) otherGraph = Graph("") otherGraph.importGraphContent(graph) otherGraph.importGraphContent(graph) assert len(otherGraph.nodes) == 4 assert len(otherGraph.compatibilityNodes) == 4 assert len(otherGraph.edges) == 2 def test_importGraphWithUnknownNodeTypesCreatesCompatibilityNodes(self): graph = Graph("") with registeredNodeTypes([SimpleNode]): graph.addNewNode(SimpleNode.__name__) otherGraph = Graph("") importedNode = otherGraph.importGraphContent(graph) assert len(importedNode) == 1 assert importedNode[0].isCompatibilityNode def test_importGraphContentInPlace(self): graph = Graph("") with registeredNodeTypes([SimpleNode]): nodeA_1 = graph.addNewNode(SimpleNode.__name__) nodeA_2 = graph.addNewNode(SimpleNode.__name__) nodeA_1.output.connectTo(nodeA_2.input) graph.importGraphContent(graph) assert len(graph.nodes) == 4 def test_importGraphContentFromFile(self, graphSavedOnDisk): graph: Graph = graphSavedOnDisk with registeredNodeTypes([SimpleNode]): nodeA_1 = graph.addNewNode(SimpleNode.__name__) nodeA_2 = graph.addNewNode(SimpleNode.__name__) nodeA_1.output.connectTo(nodeA_2.input) graph.save() otherGraph = Graph("") nodes = otherGraph.importGraphContentFromFile(graph.filepath) assert len(nodes) == 2 assert compareGraphsContent(graph, otherGraph) def test_importGraphContentFromFileWithCompatibilityNodes(self, graphSavedOnDisk): graph: Graph = graphSavedOnDisk with registeredNodeTypes([SimpleNode]): nodeA_1 = graph.addNewNode(SimpleNode.__name__) nodeA_2 = graph.addNewNode(SimpleNode.__name__) nodeA_1.output.connectTo(nodeA_2.input) graph.save() otherGraph = Graph("") nodes = otherGraph.importGraphContentFromFile(graph.filepath) assert len(nodes) == 2 assert len(otherGraph.compatibilityNodes) == 2 assert not compareGraphsContent(graph, otherGraph) def test_importingDifferentNodeVersionCreatesCompatibilityNodes(self, graphSavedOnDisk): graph: Graph = graphSavedOnDisk with registeredNodeTypes([SimpleNode]): with overrideNodeTypeVersion(SimpleNode, "1.0"): node = graph.addNewNode(SimpleNode.__name__) graph.save() with overrideNodeTypeVersion(SimpleNode, "2.0"): otherGraph = Graph("") nodes = otherGraph.importGraphContentFromFile(graph.filepath) assert len(nodes) == 1 assert len(otherGraph.compatibilityNodes) == 1 assert otherGraph.node(node.name).issue is CompatibilityIssue.VersionConflict class TestGraphSave: def test_generateNextPath(self, graphSavedOnDisk): graph: Graph = graphSavedOnDisk root = os.path.dirname(graph._filepath) # Files with no version number (e.g., "scene.mg" -> "scene1.mg") graph._filepath = os.path.join(root, "scene.mg") assertPathsAreEqual(graph._generateNextPath(), os.path.join(root, "scene1.mg")) # Files with existing version numbers (e.g., "scene1.mg" -> "scene2.mg") graph._filepath = os.path.join(root, "scene_1.mg") assertPathsAreEqual(graph._generateNextPath(), os.path.join(root, "scene_2.mg")) # Edge cases like filenames that are purely numeric (e.g., "123.mg") # Also test that the padding is kept ("001" -> "002" and not "2") graph._filepath = os.path.join(root, "0123.mg") assertPathsAreEqual(graph._generateNextPath(), os.path.join(root, "0124.mg")) graph._filepath = os.path.join(root, "scene_001.mg") assertPathsAreEqual(graph._generateNextPath(), os.path.join(root, "scene_002.mg")) # Files where the next version already exists (e.g., "scene1.mg" when "scene2.mg" exists -> "scene3.mg") graph._filepath = os.path.join(root, "scene1.mg") open(os.path.join(root, "scene2.mg"), 'a').close() assertPathsAreEqual(graph._generateNextPath(), os.path.join(root, "scene3.mg")) def test_saveAsNewVersion(self, tmp_path): graph = Graph("") with registeredNodeTypes([SimpleNode]): # Create scene nodeA = graph.addNewNode(SimpleNode.__name__) scenePath = os.path.join(tmp_path, "scene.mg") graph._filepath = scenePath graph.save() assert os.path.exists(scenePath) # Modify scene nodeB = graph.addNewNode(SimpleNode.__name__) nodeA.output.connectTo(nodeB.input) graph.saveAsNewVersion() newScenePath = os.path.join(tmp_path, "scene1.mg") assert os.path.exists(newScenePath) class TestGraphPartialSerialization: def test_emptyGraph(self): graph = Graph("") serializedGraph = graph.serializePartial([]) otherGraph = Graph("") otherGraph._deserialize(serializedGraph) assert compareGraphsContent(graph, otherGraph) def test_serializeAllNodesIsSimilarToStandardSerialization(self): graph = Graph("") with registeredNodeTypes([SimpleNode]): nodeA = graph.addNewNode(SimpleNode.__name__) nodeB = graph.addNewNode(SimpleNode.__name__) nodeA.output.connectTo(nodeB.input) partialSerializedGraph = graph.serializePartial([nodeA, nodeB]) standardSerializedGraph = graph.serialize() graphA = Graph("") graphA._deserialize(partialSerializedGraph) graphB = Graph("") graphB._deserialize(standardSerializedGraph) assert compareGraphsContent(graph, graphA) assert compareGraphsContent(graphA, graphB) def test_listAttributeToListAttributeConnectionIsSerialized(self): graph = Graph("") with registeredNodeTypes([NodeWithListAttributes]): nodeA = graph.addNewNode(NodeWithListAttributes.__name__) nodeB = graph.addNewNode(NodeWithListAttributes.__name__) nodeA.listInput.connectTo(nodeB.listInput) otherGraph = Graph("") otherGraph._deserialize(graph.serializePartial([nodeA, nodeB])) assert otherGraph.node(nodeB.name).listInput.inputLink == \ otherGraph.node(nodeA.name).listInput def test_singleNodeWithInputConnectionFromNonSerializedNodeRemovesEdge(self): graph = Graph("") with registeredNodeTypes([SimpleNode]): nodeA = graph.addNewNode(SimpleNode.__name__) nodeB = graph.addNewNode(SimpleNode.__name__) nodeA.output.connectTo(nodeB.input) serializedGraph = graph.serializePartial([nodeB]) otherGraph = Graph("") otherGraph._deserialize(serializedGraph) assert len(otherGraph.compatibilityNodes) == 0 assert len(otherGraph.nodes) == 1 assert len(otherGraph.edges) == 0 def test_serializeSingleNodeWithInputConnectionToListAttributeRemovesListEntry(self): graph = Graph("") with registeredNodeTypes([SimpleNode, NodeWithListAttributes]): nodeA = graph.addNewNode(SimpleNode.__name__) nodeB = graph.addNewNode(NodeWithListAttributes.__name__) nodeB.listInput.append("") nodeA.output.connectTo(nodeB.listInput.at(0)) otherGraph = Graph("") otherGraph._deserialize(graph.serializePartial([nodeB])) assert len(otherGraph.node(nodeB.name).listInput) == 0 def test_serializeSingleNodeWithInputConnectionToNestedListAttributeRemovesListEntry(self): graph = Graph("") with registeredNodeTypes([SimpleNode, NodeWithListAttributes]): nodeA = graph.addNewNode(SimpleNode.__name__) nodeB = graph.addNewNode(NodeWithListAttributes.__name__) nodeB.group.listInput.append("") nodeA.output.connectTo(nodeB.group.listInput.at(0)) otherGraph = Graph("") otherGraph._deserialize(graph.serializePartial([nodeB])) assert len(otherGraph.node(nodeB.name).group.listInput) == 0 class TestGraphCopy: def test_graphCopyIsIdenticalToOriginalGraph(self): graph = Graph("") with registeredNodeTypes([SimpleNode]): nodeA = graph.addNewNode(SimpleNode.__name__) nodeB = graph.addNewNode(SimpleNode.__name__) nodeA.output.connectTo(nodeB.input) graphCopy = graph.copy() assert compareGraphsContent(graph, graphCopy) def test_graphCopyWithUnknownNodeTypesDiffersFromOriginalGraph(self): graph = Graph("") with registeredNodeTypes([SimpleNode]): nodeA = graph.addNewNode(SimpleNode.__name__) nodeB = graph.addNewNode(SimpleNode.__name__) nodeA.output.connectTo(nodeB.input) graphCopy = graph.copy() assert not compareGraphsContent(graph, graphCopy) class TestImportGraphContentFromMinimalGraphData: def test_nodeWithoutVersionInfoIsUpgraded(self): graph = Graph("") with ( registeredNodeTypes([SimpleNode]), overrideNodeTypeVersion(SimpleNode, "2.0"), ): sampleGraphContent = dedent(""" { "SimpleNode_1": { "nodeType": "SimpleNode" } } """) graph._deserialize(json.loads(sampleGraphContent)) assert len(graph.nodes) == 1 assert len(graph.compatibilityNodes) == 0 def test_connectionsToMissingNodesAreDiscarded(self): graph = Graph("") with registeredNodeTypes([SimpleNode]): sampleGraphContent = dedent(""" { "SimpleNode_1": { "nodeType": "SimpleNode", "inputs": { "input": "{NotSerializedNode.output}" } } } """) graph._deserialize(json.loads(sampleGraphContent)) ================================================ FILE: tests/test_groupAttributes.py ================================================ #!/usr/bin/env python # coding:utf-8 import os import tempfile from meshroom.core.graph import Graph, loadGraph from meshroom.core.node import CompatibilityNode from meshroom.core.attribute import GroupAttribute # 1 int, 1 exclusive choice param, 1 choice param, 1 bool, 1 group, 1 float nested in the group, 2 lists GROUPATTRIBUTES_FIRSTGROUP_NB_CHILDREN = 8 GROUPATTRIBUTES_FIRSTGROUP_NESTED_NB_CHILDREN = 1 # 1 float GROUPATTRIBUTES_OUTPUTGROUP_NB_CHILDREN = 1 # 1 bool GROUPATTRIBUTES_FIRSTGROUP_DEPTHS = [1, 1, 1, 1, 1, 2, 1, 1] class TestGroupAttributes: def test_saveLoadGroupDirectConnections(self): """ Ensure that connecting GroupAttributes does not cause their nodes to have CompatibilityIssues when re-opening them. """ graph = Graph("Connections between GroupAttributes") # Create two "GroupAttributes" nodes with their default parameters nodeA = graph.addNewNode("GroupAttributes") nodeB = graph.addNewNode("GroupAttributes") # Connect attributes within groups at different depth levels nodeA.firstGroup.connectTo(nodeB.firstGroup) # Save the graph in a file graphFile = os.path.join(tempfile.mkdtemp(), "test_io_group_connections.mg") graph.save(graphFile) # Reload the graph graph = loadGraph(graphFile) assert graph.node("GroupAttributes_2").firstGroup.inputLink == graph.node("GroupAttributes_1").firstGroup def test_saveLoadGroupConnections(self): """ Ensure that connecting attributes that are part of GroupAttributes does not cause their nodes to have CompatibilityIssues when re-opening them. """ graph = Graph("Connections between subattributes in GroupAttributes") # Create two "GroupAttributes" nodes with their default parameters nodeA = graph.addNewNode("GroupAttributes") nodeB = graph.addNewNode("GroupAttributes") # Connect attributes within groups at different depth levels nodeA.firstGroup.firstGroupIntA.connectTo(nodeB.firstGroup.firstGroupIntA) nodeA.firstGroup.nestedGroup.nestedGroupFloat.connectTo( nodeB.firstGroup.nestedGroup.nestedGroupFloat) # Save the graph in a file graphFile = os.path.join(tempfile.mkdtemp(), "test_io_group_connections.mg") graph.save(graphFile) # Reload the graph graph = loadGraph(graphFile) # Ensure the nodes are not CompatibilityNodes for node in graph.nodes: assert not isinstance(node, CompatibilityNode) def test_groupAttributesFlatChildren(self): """ Check that the list of static flat children is correct, even with list elements. """ graph = Graph("Children of GroupAttributes") # Create two "GroupAttributes" nodes with their default parameters node = graph.addNewNode("GroupAttributes") intAttr = node.attribute("exposedInt") assert not isinstance(intAttr, GroupAttribute) assert len(intAttr.flatStaticChildren) == 0 # Not a Group, cannot have any child inputGroup = node.attribute("firstGroup") assert isinstance(inputGroup, GroupAttribute) assert len(inputGroup.flatStaticChildren) == GROUPATTRIBUTES_FIRSTGROUP_NB_CHILDREN # Add an element to a list within the group and check the number of children has not changed groupedList = node.attribute("firstGroup.singleGroupedList") groupedList.insert(0, 30) assert len(groupedList.flatStaticChildren) == 0 # Not a Group, elements are not counted as children assert len(inputGroup.flatStaticChildren) == GROUPATTRIBUTES_FIRSTGROUP_NB_CHILDREN nestedGroup = node.attribute("firstGroup.nestedGroup") assert isinstance(nestedGroup, GroupAttribute) assert len(nestedGroup.flatStaticChildren) == GROUPATTRIBUTES_FIRSTGROUP_NESTED_NB_CHILDREN outputGroup = node.attribute("outputGroup") assert isinstance(outputGroup, GroupAttribute) assert len(outputGroup.flatStaticChildren) == GROUPATTRIBUTES_OUTPUTGROUP_NB_CHILDREN def test_groupAttributesDepthLevels(self): """ Check that the depth level of children attributes is correctly set. """ graph = Graph("Children of GroupAttributes") # Create two "GroupAttributes" nodes with their default parameters node = graph.addNewNode("GroupAttributes") inputGroup = node.attribute("firstGroup") assert isinstance(inputGroup, GroupAttribute) assert inputGroup.depth == 0 # Root level cnt = 0 for child in inputGroup.flatStaticChildren: assert child.depth == GROUPATTRIBUTES_FIRSTGROUP_DEPTHS[cnt] cnt = cnt + 1 outputGroup = node.attribute("outputGroup") assert isinstance(outputGroup, GroupAttribute) assert outputGroup.depth == 0 for child in outputGroup.flatStaticChildren: # Single element in the group assert child.depth == 1 intAttr = node.attribute("exposedInt") assert not isinstance(intAttr, GroupAttribute) assert intAttr.depth == 0 def test_groupAttributesWithMatchingStructure(self): """ Check that two different GroupAttributes can be connected if they have a matching structure. """ # Given graph = Graph() nestedPosition = graph.addNewNode("NestedPosition") nestedColor = graph.addNewNode("NestedColor") # When acceptedConnection = nestedPosition.xyz.validateIncomingConnection(nestedColor.rgb) # Then assert acceptedConnection def test_groupAttributesWithDifferentStructures(self): """ Check that two different GroupAttributes cannot be connected if they have different structures. """ # Given graph = Graph() nestedPosition = graph.addNewNode("NestedPosition") nestedTest = graph.addNewNode("NestedTest") # When acceptedConnection = nestedPosition.xyz.validateIncomingConnection(nestedTest.xyz) # Then assert not acceptedConnection def test_connectGroupsWithSubAttributes(self): """ Check that when a group is connected to another group, all the sub-attributes are connected together automatically. """ # Given graph = Graph() nestedColor = graph.addNewNode("NestedColor") nestedPosition = graph.addNewNode("NestedPosition") assert not nestedPosition.xyz.isLink assert not nestedPosition.xyz.x.isLink assert not nestedPosition.xyz.y.isLink assert not nestedPosition.xyz.z.isLink assert not nestedPosition.xyz.test.isLink assert not nestedPosition.xyz.test.x.isLink assert not nestedPosition.xyz.test.y.isLink assert not nestedPosition.xyz.test.z.isLink # When nestedColor.rgb.connectTo(nestedPosition.xyz) # Then assert nestedPosition.xyz.isLink and \ nestedPosition.xyz.inputLink.asLinkExpr() == nestedColor.rgb.asLinkExpr() assert nestedPosition.xyz.x.isLink and \ nestedPosition.xyz.x.inputLink.asLinkExpr() == nestedColor.rgb.r.asLinkExpr() assert nestedPosition.xyz.y.isLink and \ nestedPosition.xyz.y.inputLink.asLinkExpr() == nestedColor.rgb.g.asLinkExpr() assert nestedPosition.xyz.z.isLink and \ nestedPosition.xyz.z.inputLink.asLinkExpr() == nestedColor.rgb.b.asLinkExpr() assert nestedPosition.xyz.test.isLink and \ nestedPosition.xyz.test.inputLink.asLinkExpr() == nestedColor.rgb.test.asLinkExpr() assert nestedPosition.xyz.test.x.isLink and \ nestedPosition.xyz.test.x.inputLink.asLinkExpr() == nestedColor.rgb.test.r.asLinkExpr() assert nestedPosition.xyz.test.y.isLink and \ nestedPosition.xyz.test.y.inputLink.asLinkExpr() == nestedColor.rgb.test.g.asLinkExpr() assert nestedPosition.xyz.test.z.isLink and \ nestedPosition.xyz.test.z.inputLink.asLinkExpr() == nestedColor.rgb.test.b.asLinkExpr() # Save the graph in a file graphFile = os.path.join(tempfile.mkdtemp(), "test_io_group_connections.mg") graph.save(graphFile) # Reload the graph graph = loadGraph(graphFile) nestedPosition = graph.node("NestedPosition_1") nestedColor = graph.node("NestedColor_1") assert nestedPosition.xyz.isLink and \ nestedPosition.xyz.inputLink.asLinkExpr() == nestedColor.rgb.asLinkExpr() assert nestedPosition.xyz.x.isLink and \ nestedPosition.xyz.x.inputLink.asLinkExpr() == nestedColor.rgb.r.asLinkExpr() assert nestedPosition.xyz.y.isLink and \ nestedPosition.xyz.y.inputLink.asLinkExpr() == nestedColor.rgb.g.asLinkExpr() assert nestedPosition.xyz.z.isLink and \ nestedPosition.xyz.z.inputLink.asLinkExpr() == nestedColor.rgb.b.asLinkExpr() assert nestedPosition.xyz.test.isLink and \ nestedPosition.xyz.test.inputLink.asLinkExpr() == nestedColor.rgb.test.asLinkExpr() assert nestedPosition.xyz.test.x.isLink and \ nestedPosition.xyz.test.x.inputLink.asLinkExpr() == nestedColor.rgb.test.r.asLinkExpr() assert nestedPosition.xyz.test.y.isLink and \ nestedPosition.xyz.test.y.inputLink.asLinkExpr() == nestedColor.rgb.test.g.asLinkExpr() assert nestedPosition.xyz.test.z.isLink and \ nestedPosition.xyz.test.z.inputLink.asLinkExpr() == nestedColor.rgb.test.b.asLinkExpr() def test_connectSubAttributes(self): """ After a group has been connected to another group, connecting individually a sub-attribute should disconnect the group itself. """ # Given graph = Graph() nestedColor = graph.addNewNode("NestedColor") nestedPosition = graph.addNewNode("NestedPosition") nestedColor.rgb.connectTo(nestedPosition.xyz) assert nestedPosition.xyz.isLink and \ nestedPosition.xyz.inputLink.asLinkExpr() == nestedColor.rgb.asLinkExpr() assert nestedPosition.xyz.x.isLink and \ nestedPosition.xyz.x.inputLink.asLinkExpr() == nestedColor.rgb.r.asLinkExpr() assert nestedPosition.xyz.y.isLink and \ nestedPosition.xyz.y.inputLink.asLinkExpr() == nestedColor.rgb.g.asLinkExpr() assert nestedPosition.xyz.z.isLink and \ nestedPosition.xyz.z.inputLink.asLinkExpr() == nestedColor.rgb.b.asLinkExpr() assert nestedPosition.xyz.test.isLink and \ nestedPosition.xyz.test.inputLink.asLinkExpr() == nestedColor.rgb.test.asLinkExpr() assert nestedPosition.xyz.test.x.isLink and \ nestedPosition.xyz.test.x.inputLink.asLinkExpr() == nestedColor.rgb.test.r.asLinkExpr() assert nestedPosition.xyz.test.y.isLink and \ nestedPosition.xyz.test.y.inputLink.asLinkExpr() == nestedColor.rgb.test.g.asLinkExpr() assert nestedPosition.xyz.test.z.isLink and \ nestedPosition.xyz.test.z.inputLink.asLinkExpr() == nestedColor.rgb.test.b.asLinkExpr() # When r = nestedColor.rgb.r z = nestedPosition.xyz.test.z r.connectTo(z) # Then assert not nestedPosition.xyz.isLink # Disconnected because sub GroupAttribute has been disconnected assert nestedPosition.xyz.x.isLink and \ nestedPosition.xyz.x.inputLink.asLinkExpr() == nestedColor.rgb.r.asLinkExpr() assert nestedPosition.xyz.y.isLink and \ nestedPosition.xyz.y.inputLink.asLinkExpr() == nestedColor.rgb.g.asLinkExpr() assert nestedPosition.xyz.z.isLink and \ nestedPosition.xyz.z.inputLink.asLinkExpr() == nestedColor.rgb.b.asLinkExpr() assert not nestedPosition.xyz.test.isLink # Disconnected because nestedPosition.xyz.test.z has been reconnected assert nestedPosition.xyz.test.x.isLink and \ nestedPosition.xyz.test.x.inputLink.asLinkExpr() == nestedColor.rgb.test.r.asLinkExpr() assert nestedPosition.xyz.test.y.isLink and \ nestedPosition.xyz.test.y.inputLink.asLinkExpr() == nestedColor.rgb.test.g.asLinkExpr() assert nestedPosition.xyz.test.z.isLink and \ nestedPosition.xyz.test.z.inputLink.asLinkExpr() == r.asLinkExpr() == nestedColor.rgb.r.asLinkExpr() def test_connectGroupSubAttributesByValue(self): """ Check that sub-attributes are connected by value and not by reference. When connected to another sub-attribute through a group connection, a given sub-attribute should have an address that differs from the incoming sub-attribute. """ graph = Graph() groupA = graph.addNewNode("GroupAttributes") groupB = graph.addNewNode("GroupAttributes") groupA.firstGroup.firstGroupIntA.value = 1234 assert groupA.firstGroup.firstGroupIntA.value != groupB.firstGroup.firstGroupIntA.value # Connect the groups groupA.firstGroup.connectTo(groupB.firstGroup) subAttributeA = groupA.firstGroup.firstGroupIntA subAttributeB = groupB.firstGroup.firstGroupIntA assert subAttributeA != subAttributeB assert subAttributeB.isLink assert subAttributeA.fullName != subAttributeB.fullName assert groupA.firstGroup.firstGroupIntA.value == groupB.firstGroup.firstGroupIntA.value == 1234 ================================================ FILE: tests/test_invalidation.py ================================================ #!/usr/bin/env python # coding:utf-8 from meshroom.core.graph import Graph from meshroom.core import desc from .utils import registerNodeDesc, unregisterNodeDesc class SampleNode(desc.Node): """ Sample Node for unit testing """ inputs = [ desc.File(name="input", label="Input", description="", value="",), desc.StringParam(name="paramA", label="ParamA", description="", value="", invalidate=False) # No impact on UID ] outputs = [ desc.File(name='output', label='Output', description='', value="{nodeCacheFolder}") ] def test_output_invalidation(): registerNodeDesc(SampleNode) # Register standalone NodePlugin graph = Graph("") n1 = graph.addNewNode("SampleNode", input="/tmp") n2 = graph.addNewNode("SampleNode") n3 = graph.addNewNode("SampleNode") n1.output.connectTo(n2.input) n1.output.connectTo(n3.input) # N1.output ----- N2.input # \ # N3.input # Compare UIDs of similar attributes on different nodes n2inputUid = n2.input.uid() n3inputUid = n3.input.uid() assert n3inputUid == n2inputUid # => UIDs are equal # Change a parameter outside UID n1.paramA.value = 'a' assert n2.input.uid() == n2inputUid # => same UID as before # Change a parameter impacting UID n1.input.value = "/a/path" assert n2.input.uid() != n2inputUid # => UID has changed assert n2.input.uid() == n3.input.uid() # => UIDs on both node are still equal unregisterNodeDesc(SampleNode) def test_inputLinkInvalidation(): """ Input links should not change the invalidation. """ registerNodeDesc(SampleNode) # Register standalone NodePlugin graph = Graph("") n1 = graph.addNewNode("SampleNode") n2 = graph.addNewNode("SampleNode") n1.input.connectTo(n2.input) assert n1.input.uid() == n2.input.uid() assert n1.output.value == n2.output.value unregisterNodeDesc(SampleNode) ================================================ FILE: tests/test_listAttribute.py ================================================ from meshroom.core import desc from meshroom.core.graph import Graph from .utils import registerNodeDesc, unregisterNodeDesc class NodeWithListAttribute(desc.Node): inputs = [ desc.ListAttribute( name="listInput", label="List Input", description="ListAttribute of StringParams.", elementDesc=desc.StringParam(name="value", label="Value", description="", value=""), ) ] class TestListAttribute: @classmethod def setup_class(cls): registerNodeDesc(NodeWithListAttribute) @classmethod def teardown_class(cls): unregisterNodeDesc(NodeWithListAttribute) def test_lengthUsesLinkParam(self): graph = Graph("") nodeA = graph.addNewNode(NodeWithListAttribute.__name__) nodeB = graph.addNewNode(NodeWithListAttribute.__name__) nodeA.listInput.connectTo(nodeB.listInput) nodeA.listInput.append("test") assert len(nodeB.listInput) == 1 def test_iterationUsesLinkParam(self): graph = Graph("") nodeA = graph.addNewNode(NodeWithListAttribute.__name__) nodeB = graph.addNewNode(NodeWithListAttribute.__name__) nodeA.listInput.connectTo(nodeB.listInput) nodeA.listInput.extend(["A", "B", "C"]) for value in nodeB.listInput: assert value.node == nodeA def test_elementAccessUsesLinkParam(self): graph = Graph("") nodeA = graph.addNewNode(NodeWithListAttribute.__name__) nodeB = graph.addNewNode(NodeWithListAttribute.__name__) nodeA.listInput.connectTo(nodeB.listInput) nodeA.listInput.extend(["A", "B", "C"]) assert nodeB.listInput.at(0).node == nodeA assert nodeB.listInput.index(nodeB.listInput.at(0)) == 0 ================================================ FILE: tests/test_model.py ================================================ import pytest from PySide6.QtCore import QObject, Property from meshroom.common.core import CoreDictModel from meshroom.common.qt import QObjectListModel, QTypedObjectListModel class DummyNode(QObject): def __init__(self, name="", parent=None): super(DummyNode, self).__init__(parent) self._name = name def getName(self): return self._name name = Property(str, getName) def test_DictModel_add_remove(): for DictModel in (CoreDictModel, QObjectListModel): m = DictModel(keyAttrName='name') node = DummyNode("DummyNode_1") m.add(node) assert len(m) == 1 assert len(m.keys()) == 1 assert len(m.values()) == 1 assert m.get("DummyNode_1") == node assert m.get("something") is None with pytest.raises(KeyError): m.getr("something") m.pop("DummyNode_1") assert len(m) == 0 assert len(m.keys()) == 0 assert len(m.values()) == 0 def test_listModel_typed_add(): m = QTypedObjectListModel(T=DummyNode) assert m.roleForName('name') != -1 node = DummyNode("DummyNode_1") m.add(node) assert m.data(m.index(0), m.roleForName('name')) == "DummyNode_1" obj = QObject() with pytest.raises(TypeError): m.add(obj) ================================================ FILE: tests/test_nodeAttributeChangedCallback.py ================================================ # coding:utf-8 from meshroom.core.graph import Graph, loadGraph, executeGraph from meshroom.core import desc from meshroom.core.node import Node from .utils import registerNodeDesc, unregisterNodeDesc class NodeWithAttributeChangedCallback(desc.BaseNode): """ A Node containing an input Attribute with an 'on{Attribute}Changed' method, called whenever the value of this attribute is changed explicitly. """ inputs = [ desc.IntParam( name="input", label="Input", description="Attribute with a value changed callback (onInputChanged)", value=0, range=None, ), desc.IntParam( name="affectedInput", label="Affected Input", description="Updated to input.value * 2 whenever 'input' is explicitly modified", value=0, range=None, ), ] def onInputChanged(self, instance: Node): instance.affectedInput.value = instance.input.value * 2 def processChunk(self, chunk): pass # No-op. class TestNodeWithAttributeChangedCallback: @classmethod def setup_class(cls): registerNodeDesc(NodeWithAttributeChangedCallback) @classmethod def teardown_class(cls): unregisterNodeDesc(NodeWithAttributeChangedCallback) def test_assignValueTriggersCallback(self): node = Node(NodeWithAttributeChangedCallback.__name__) assert node.affectedInput.value == 0 node.input.value = 10 assert node.affectedInput.value == 20 def test_specifyDefaultValueDoesNotTriggerCallback(self): node = Node(NodeWithAttributeChangedCallback.__name__, input=10) assert node.affectedInput.value == 0 def test_assignDefaultValueDoesNotTriggerCallback(self): node = Node(NodeWithAttributeChangedCallback.__name__, input=10) node.input.value = 10 assert node.affectedInput.value == 0 def test_assignNonDefaultValueTriggersCallback(self): node = Node(NodeWithAttributeChangedCallback.__name__, input=10) node.input.value = 2 assert node.affectedInput.value == 4 class TestAttributeCallbackTriggerInGraph: @classmethod def setup_class(cls): registerNodeDesc(NodeWithAttributeChangedCallback) @classmethod def teardown_class(cls): unregisterNodeDesc(NodeWithAttributeChangedCallback) def test_connectionTriggersCallback(self): graph = Graph("") nodeA = graph.addNewNode(NodeWithAttributeChangedCallback.__name__) nodeB = graph.addNewNode(NodeWithAttributeChangedCallback.__name__) assert nodeA.affectedInput.value == nodeB.affectedInput.value == 0 nodeA.input.value = 1 nodeA.input.connectTo(nodeB.input) assert nodeA.affectedInput.value == nodeB.affectedInput.value == 2 def test_connectedValueChangeTriggersCallback(self): graph = Graph("") nodeA = graph.addNewNode(NodeWithAttributeChangedCallback.__name__) nodeB = graph.addNewNode(NodeWithAttributeChangedCallback.__name__) assert nodeA.affectedInput.value == nodeB.affectedInput.value == 0 nodeA.input.connectTo(nodeB.input) nodeA.input.value = 1 assert nodeA.affectedInput.value == 2 assert nodeB.affectedInput.value == 2 def test_defaultValueOnlyTriggersCallbackDownstream(self): graph = Graph("") nodeA = graph.addNewNode(NodeWithAttributeChangedCallback.__name__, input=1) nodeB = graph.addNewNode(NodeWithAttributeChangedCallback.__name__) assert nodeA.affectedInput.value == 0 assert nodeB.affectedInput.value == 0 nodeA.input.connectTo(nodeB.input) assert nodeA.affectedInput.value == 0 assert nodeB.affectedInput.value == 2 def test_valueChangeIsPropagatedAlongNodeChain(self): graph = Graph("") nodeA = graph.addNewNode(NodeWithAttributeChangedCallback.__name__) nodeB = graph.addNewNode(NodeWithAttributeChangedCallback.__name__) nodeC = graph.addNewNode(NodeWithAttributeChangedCallback.__name__) nodeD = graph.addNewNode(NodeWithAttributeChangedCallback.__name__) nodeA.affectedInput.connectTo(nodeB.input) nodeB.affectedInput.connectTo(nodeC.input) nodeC.affectedInput.connectTo(nodeD.input) nodeA.input.value = 5 assert nodeA.affectedInput.value == nodeB.input.value == 10 assert nodeB.affectedInput.value == nodeC.input.value == 20 assert nodeC.affectedInput.value == nodeD.input.value == 40 assert nodeD.affectedInput.value == 80 def test_disconnectionTriggersCallback(self): graph = Graph("") nodeA = graph.addNewNode(NodeWithAttributeChangedCallback.__name__) nodeB = graph.addNewNode(NodeWithAttributeChangedCallback.__name__) nodeA.input.connectTo(nodeB.input) nodeA.input.value = 5 assert nodeB.affectedInput.value == 10 graph.removeEdge(nodeB.input) assert nodeB.input.value == 0 assert nodeB.affectedInput.value == 0 def test_loadingGraphDoesNotTriggerCallback(self, graphSavedOnDisk): graph: Graph = graphSavedOnDisk node = graph.addNewNode(NodeWithAttributeChangedCallback.__name__) node.input.value = 5 node.affectedInput.value = 2 graph.save() loadedGraph = loadGraph(graph.filepath, strictCompatibility=True) loadedNode = loadedGraph.node(node.name) assert loadedNode assert loadedNode.affectedInput.value == 2 def test_loadingGraphDoesNotTriggerCallbackForConnectedAttributes( self, graphSavedOnDisk ): graph: Graph = graphSavedOnDisk nodeA = graph.addNewNode(NodeWithAttributeChangedCallback.__name__) nodeB = graph.addNewNode(NodeWithAttributeChangedCallback.__name__) nodeA.input.connectTo(nodeB.input) nodeA.input.value = 5 assert nodeB.affectedInput.value == nodeB.input.value * 2 nodeB.affectedInput.value = 2 graph.save() loadedGraph = loadGraph(graph.filepath, strictCompatibility=True) loadedNodeB = loadedGraph.node(nodeB.name) assert loadedNodeB assert loadedNodeB.affectedInput.value == 2 class NodeWithCompoundAttributes(desc.BaseNode): """ A Node containing a variation of compound attributes (List/Groups), called whenever the value of this attribute is changed explicitly. """ inputs = [ desc.ListAttribute( name="listInput", label="List Input", description="ListAttribute of IntParams.", elementDesc=desc.IntParam( name="int", label="Int", description="", value=0, range=None ), ), desc.GroupAttribute( name="groupInput", label="Group Input", description="GroupAttribute with a single 'IntParam' element.", items=[ desc.IntParam( name="int", label="Int", description="", value=0, range=None ) ], ), desc.ListAttribute( name="listOfGroupsInput", label="List of Groups input", description="ListAttribute of GroupAttribute with a single 'IntParam' element.", elementDesc=desc.GroupAttribute( name="subGroup", label="SubGroup", description="", items=[ desc.IntParam( name="int", label="Int", description="", value=0, range=None ) ], ) ), desc.GroupAttribute( name="groupWithListInput", label="Group with List", description="GroupAttribute with a single 'ListAttribute of IntParam' element.", items=[ desc.ListAttribute( name="subList", label="SubList", description="", elementDesc=desc.IntParam( name="int", label="Int", description="", value=0, range=None ) ) ] ) ] class TestAttributeCallbackBehaviorWithUpstreamCompoundAttributes: @classmethod def setup_class(cls): registerNodeDesc(NodeWithAttributeChangedCallback) registerNodeDesc(NodeWithCompoundAttributes) @classmethod def teardown_class(cls): unregisterNodeDesc(NodeWithAttributeChangedCallback) unregisterNodeDesc(NodeWithCompoundAttributes) def test_connectionToListElement(self): graph = Graph("") nodeA = graph.addNewNode(NodeWithCompoundAttributes.__name__) nodeB = graph.addNewNode(NodeWithAttributeChangedCallback.__name__) nodeA.listInput.append(0) attr = nodeA.listInput.at(0) attr.connectTo(nodeB.input) attr.value = 10 assert nodeB.input.value == 10 assert nodeB.affectedInput.value == 20 def test_connectionToGroupElement(self): graph = Graph("") nodeA = graph.addNewNode(NodeWithCompoundAttributes.__name__) nodeB = graph.addNewNode(NodeWithAttributeChangedCallback.__name__) nodeA.groupInput.int.connectTo(nodeB.input) nodeA.groupInput.int.value = 10 assert nodeB.input.value == 10 assert nodeB.affectedInput.value == 20 def test_connectionToGroupElementInList(self): graph = Graph("") nodeA = graph.addNewNode(NodeWithCompoundAttributes.__name__) nodeB = graph.addNewNode(NodeWithAttributeChangedCallback.__name__) nodeA.listOfGroupsInput.append({}) attr = nodeA.listOfGroupsInput.at(0) attr.int.connectTo(nodeB.input) attr.int.value = 10 assert nodeB.input.value == 10 assert nodeB.affectedInput.value == 20 def test_connectionToListElementInGroup(self): graph = Graph("") nodeA = graph.addNewNode(NodeWithCompoundAttributes.__name__) nodeB = graph.addNewNode(NodeWithAttributeChangedCallback.__name__) nodeA.groupWithListInput.subList.append(0) attr = nodeA.groupWithListInput.subList.at(0) attr.connectTo(nodeB.input) attr.value = 10 assert nodeB.input.value == 10 assert nodeB.affectedInput.value == 20 class NodeWithDynamicOutputValue(desc.BaseNode): """ A Node containing an output attribute which value is computed dynamically during graph execution. """ inputs = [ desc.IntParam( name="input", label="Input", description="Input used in the computation of 'output'", value=0, ), ] outputs = [ desc.IntParam( name="output", label="Output", description="Dynamically computed output (input * 2)", # Setting value to None makes the attribute dynamic. value=None, ), ] def processChunk(self, chunk): chunk.node.output.value = chunk.node.input.value * 2 class TestAttributeCallbackBehaviorWithUpstreamDynamicOutputs: # nodePluginAttributeChangedCallback = NodePlugin(NodeWithAttributeChangedCallback) # nodePluginDynamicOutputValue = NodePlugin(NodeWithDynamicOutputValue) @classmethod def setup_class(cls): registerNodeDesc(NodeWithAttributeChangedCallback) registerNodeDesc(NodeWithDynamicOutputValue) @classmethod def teardown_class(cls): unregisterNodeDesc(NodeWithAttributeChangedCallback) unregisterNodeDesc(NodeWithDynamicOutputValue) def test_connectingUncomputedDynamicOutputDoesNotTriggerDownstreamAttributeChangedCallback( self, ): graph = Graph("") nodeA = graph.addNewNode(NodeWithDynamicOutputValue.__name__) nodeB = graph.addNewNode(NodeWithAttributeChangedCallback.__name__) nodeA.input.value = 10 nodeA.output.connectTo(nodeB.input) assert nodeB.affectedInput.value == 0 def test_connectingComputedDynamicOutputTriggersDownstreamAttributeChangedCallback( self, graphSavedOnDisk ): graph: Graph = graphSavedOnDisk nodeA = graph.addNewNode(NodeWithDynamicOutputValue.__name__) nodeB = graph.addNewNode(NodeWithAttributeChangedCallback.__name__) nodeA.input.value = 10 executeGraph(graph) nodeA.output.connectTo(nodeB.input) assert nodeA.output.value == nodeB.input.value == 20 assert nodeB.affectedInput.value == 40 def test_dynamicOutputValueComputeDoesNotTriggerDownstreamAttributeChangedCallback( self, graphSavedOnDisk ): graph: Graph = graphSavedOnDisk nodeA = graph.addNewNode(NodeWithDynamicOutputValue.__name__) nodeB = graph.addNewNode(NodeWithAttributeChangedCallback.__name__) nodeA.output.connectTo(nodeB.input) nodeA.input.value = 10 executeGraph(graph) assert nodeB.input.value == 20 assert nodeB.affectedInput.value == 0 def test_clearingDynamicOutputValueDoesNotTriggerDownstreamAttributeChangedCallback( self, graphSavedOnDisk ): graph: Graph = graphSavedOnDisk nodeA = graph.addNewNode(NodeWithDynamicOutputValue.__name__) nodeB = graph.addNewNode(NodeWithAttributeChangedCallback.__name__) nodeA.input.value = 10 executeGraph(graph) nodeA.output.connectTo(nodeB.input) expectedPreClearValue = nodeA.input.value * 2 * 2 assert nodeB.affectedInput.value == expectedPreClearValue nodeA.clearData() assert nodeA.output.value == nodeB.input.value is None assert nodeB.affectedInput.value == expectedPreClearValue def test_loadingGraphWithComputedDynamicOutputValueDoesNotTriggerDownstreamAttributeChangedCallback( self, graphSavedOnDisk ): graph: Graph = graphSavedOnDisk nodeA = graph.addNewNode(NodeWithDynamicOutputValue.__name__) nodeB = graph.addNewNode(NodeWithAttributeChangedCallback.__name__) nodeA.input.value = 10 nodeA.output.connectTo(nodeB.input) executeGraph(graph) assert nodeA.output.value == nodeB.input.value == 20 assert nodeB.affectedInput.value == 0 graph.save() loadGraph(graph.filepath, strictCompatibility=True) assert nodeB.affectedInput.value == 0 class TestAttributeCallbackBehaviorOnGraphImport: @classmethod def setup_class(cls): registerNodeDesc(NodeWithAttributeChangedCallback) @classmethod def teardown_class(cls): unregisterNodeDesc(NodeWithAttributeChangedCallback) def test_importingGraphDoesNotTriggerAttributeChangedCallbacks(self): graph = Graph("") nodeA = graph.addNewNode(NodeWithAttributeChangedCallback.__name__) nodeB = graph.addNewNode(NodeWithAttributeChangedCallback.__name__) nodeA.affectedInput.connectTo(nodeB.input) nodeA.input.value = 5 nodeB.affectedInput.value = 2 otherGraph = Graph("") otherGraph.importGraphContent(graph) assert otherGraph.node(nodeB.name).affectedInput.value == 2 ================================================ FILE: tests/test_nodeAttributesFormatting.py ================================================ #!/usr/bin/env python # coding:utf-8 from meshroom.core.graph import Graph from meshroom.core import desc from .utils import registerNodeDesc, unregisterNodeDesc class NodeWithAttributesNeedingFormatting(desc.Node): """ A node containing list, file, choice and group attributes in order to test the formatting of the command line. """ inputs = [ desc.ListAttribute( name="images", label="Images", description="List of images.", elementDesc=desc.File( name="image", label="Image", description="Path to an image.", value="", ), ), desc.File( name="input", label="Input File", description="An input file.", value="", ), desc.ChoiceParam( name="method", label="Method", description="Method to choose from a list of available methods.", value="MethodC", values=["MethodA", "MethodB", "MethodC"], ), desc.GroupAttribute( name="firstGroup", label="First Group", description="Group with boolean and integer parameters.", joinChar=":", items=[ desc.BoolParam( name="enableFirstGroup", label="Enable", description="Enable other parameter in the group.", value=False, ), desc.IntParam( name="width", label="Width", description="Width setting.", value=3, range=(1, 10, 1), enabled=lambda node: node.firstGroup.enableFirstGroup.value, ), ] ), desc.GroupAttribute( name="secondGroup", label="Second Group", description="Group with boolean, choice and float parameters.", joinChar=",", items=[ desc.BoolParam( name="enableSecondGroup", label="Enable", description="Enable other parameters in the group.", value=False, ), desc.ChoiceParam( name="groupChoice", label="Grouped Choice", description="Value to choose from a group.", value="second_value", values=["first_value", "second_value", "third_value"], enabled=lambda node: node.secondGroup.enableSecondGroup.value, ), desc.FloatParam( name="floatWidth", label="Width", description="Width setting (but with a float).", value=3.0, range=(1.0, 10.0, 0.5), enabled=lambda node: node.secondGroup.enableSecondGroup.value, ), ], ), ] outputs = [ desc.File( name="output", label="Output", description="Output file.", value="{nodeCacheFolder}", ), ] class TestAttributesFormatting: @classmethod def setup_class(cls): registerNodeDesc(NodeWithAttributesNeedingFormatting) @classmethod def teardown_class(cls): unregisterNodeDesc(NodeWithAttributesNeedingFormatting) def test_formatting_listOfFiles(self): inputImages = ["/non/existing/fileA", "/non/existing/with space/fileB"] graph = Graph("") node = graph.addNewNode("NodeWithAttributesNeedingFormatting") # Assert that an empty list gives an empty string assert node.images.getValueStr() == "" # Assert that values in a list a correctly concatenated node.images.extend([i for i in inputImages]) assert node.images.getValueStr() == '"/non/existing/fileA" "/non/existing/with space/fileB"' # Reset list content and add a single value that contains spaces node.images.resetToDefaultValue() assert node.images.getValueStr() == "" # The value has been correctly reset node.images.extend("single value with space") assert node.images.getValueStr() == '"single value with space"' # Assert that extending values when the list is not empty is working node.images.extend(inputImages) assert node.images.getValueStr() == \ '"single value with space" "{}" "{}"'.format(inputImages[0], inputImages[1]) # Values are not retrieved as strings in the command line, so quotes around them are # not expected assert node._expVars["imagesValue"] == \ 'single value with space {} {}'.format(inputImages[0], inputImages[1]) def test_formatting_strings(self): graph = Graph("") node = graph.addNewNode("NodeWithAttributesNeedingFormatting") node._buildExpVars() # Assert an empty File attribute generates empty quotes when requesting its value as # a string assert node.input.getValueStr() == '""' assert node._expVars["inputValue"] == "" # Assert a Choice attribute with a non-empty default value is surrounded with quotes # when requested as a string assert node.method.getValueStr() == '"MethodC"' assert node._expVars["methodValue"] == "MethodC" # Assert that the empty list is really empty (no quotes) assert node.images.getValueStr() == "" assert node._expVars["imagesValue"] == "", "Empty list should become fully empty" # Assert that the list with one empty value generates empty quotes node.images.extend("") assert node.images.getValueStr() == '""', \ "A list with one empty string should generate empty quotes" assert node._expVars["imagesValue"] == "", \ "The value is always only the value, so empty here" # Assert that a list with 2 empty strings generates quotes node.images.extend("") assert node.images.getValueStr() == '"" ""', \ "A list with 2 empty strings should generate quotes" assert node._expVars["imagesValue"] == ' ', \ "The value is always only the value, so 2 empty strings with the " \ "space separator in the middle" def test_formatting_groups(self): graph = Graph("") node = graph.addNewNode("NodeWithAttributesNeedingFormatting") node._buildExpVars() assert node.firstGroup.getValueStr() == '"False:3"' assert node._expVars["firstGroupValue"] == 'False:3', \ "There should be no quotes here as the value is not formatted as a string" assert node.secondGroup.getValueStr() == '"False,second_value,3.0"' assert node._expVars["secondGroupValue"] == 'False,second_value,3.0' ================================================ FILE: tests/test_nodeCallbacks.py ================================================ from meshroom.core import desc from meshroom.core.node import Node from meshroom.core.graph import Graph, loadGraph from .utils import registerNodeDesc, unregisterNodeDesc class NodeWithCreationCallback(desc.InputNode): """Node defining an 'onNodeCreated' callback, triggered a new node is added to a Graph.""" inputs = [ desc.BoolParam( name="triggered", label="Triggered", description="Attribute impacted by the `onNodeCreated` callback", value=False, ), ] @classmethod def onNodeCreated(cls, node: Node): """Triggered when a new node is created within a Graph.""" node.triggered.value = True class TestNodeCreationCallback: @classmethod def setup_class(cls): registerNodeDesc(NodeWithCreationCallback) @classmethod def teardown_class(cls): unregisterNodeDesc(NodeWithCreationCallback) def test_notTriggeredOnNodeInstantiation(self): node = Node(NodeWithCreationCallback.__name__) assert node.triggered.value is False def test_triggeredOnNewNodeCreationInGraph(self): graph = Graph("") node = graph.addNewNode(NodeWithCreationCallback.__name__) assert node.triggered.value is True def test_notTriggeredOnNodeDuplication(self): graph = Graph("") node = graph.addNewNode(NodeWithCreationCallback.__name__) node.triggered.resetToDefaultValue() duplicates = graph.duplicateNodes([node]) assert duplicates[node][0].triggered.value is False def test_notTriggeredOnGraphLoad(self, graphSavedOnDisk): graph: Graph = graphSavedOnDisk node = graph.addNewNode(NodeWithCreationCallback.__name__) node.triggered.resetToDefaultValue() graph.save() loadedGraph = loadGraph(graph.filepath) assert loadedGraph.node(node.name).triggered.value is False def test_triggeredOnGraphInitializationFromTemplate(self, graphSavedOnDisk): graph: Graph = graphSavedOnDisk node = graph.addNewNode(NodeWithCreationCallback.__name__) node.triggered.resetToDefaultValue() graph.save(template=True) graphFromTemplate = Graph("") graphFromTemplate.initFromTemplate(graph.filepath) assert graphFromTemplate.node(node.name).triggered.value is True ================================================ FILE: tests/test_nodeCommandLineFormatting.py ================================================ #!/usr/bin/env python # coding:utf-8 from meshroom.core.graph import Graph from meshroom.core import desc from .utils import registerNodeDesc, unregisterNodeDesc class NodeWithCommandLineFormatting_usingNodeAndLambda(desc.CommandLineNode): """ A node using a lambda for the commandLine member variable. """ commandLine = lambda node: f"myapp --input {node.input.value} --output {node.output.value}" inputs = [ desc.File( name="input", label="Input File", description="An input file.", value="/some/input", ), ] outputs = [ desc.File( name="output", label="Output", description="Output file.", value="output.txt", ), ] def customFunction_commandline(node): return f"myapp --input {node.input.value} --output {node.output.value}" class NodeWithCommandLineFormatting_usingNodeAndFunction(desc.CommandLineNode): """ A node using a function for the commandLine member variable. """ commandLine = customFunction_commandline inputs = [ desc.File( name="input", label="Input File", description="An input file.", value="/some/input", ), ] outputs = [ desc.File( name="output", label="Output", description="Output file.", value="output.txt", ), ] class NodeWithCommandLineFormatting_usingNode(desc.CommandLineNode): """ A node using a lambda for the commandLine member variable. """ commandLine = "myapp --input {node.input.value} --output {node.output.value}" inputs = [ desc.File( name="input", label="Input File", description="An input file.", value="/some/input", ), ] outputs = [ desc.File( name="output", label="Output", description="Output file.", value="output.txt", ), ] class NodeWithCommandLineFormatting_usingValue(desc.CommandLineNode): """ A node using a string template for the commandLine member variable. """ commandLine = "myapp --input {inputValue} --output {outputValue}" inputs = [ desc.File( name="input", label="Input File", description="An input file.", value="/some/input", ), ] outputs = [ desc.File( name="output", label="Output", description="Output file.", value="output.txt", ), ] class TestCommandLineFormatting: @classmethod def setup_class(cls): registerNodeDesc(NodeWithCommandLineFormatting_usingNodeAndLambda) registerNodeDesc(NodeWithCommandLineFormatting_usingNodeAndFunction) registerNodeDesc(NodeWithCommandLineFormatting_usingNode) registerNodeDesc(NodeWithCommandLineFormatting_usingValue) @classmethod def teardown_class(cls): unregisterNodeDesc(NodeWithCommandLineFormatting_usingNodeAndLambda) unregisterNodeDesc(NodeWithCommandLineFormatting_usingNodeAndFunction) unregisterNodeDesc(NodeWithCommandLineFormatting_usingNode) unregisterNodeDesc(NodeWithCommandLineFormatting_usingValue) def test_commandLine_node(self): graph = Graph("") nodeNL = graph.addNewNode("NodeWithCommandLineFormatting_usingNodeAndLambda") nodeNF = graph.addNewNode("NodeWithCommandLineFormatting_usingNodeAndFunction") nodeN = graph.addNewNode("NodeWithCommandLineFormatting_usingNode") nodeV = graph.addNewNode("NodeWithCommandLineFormatting_usingValue") nodeNL.input.value = "/path/in" nodeNF.input.value = "/path/in" nodeN.input.value = "/path/in" nodeV.input.value = "/path/in" nodeNL._buildExpVars() # populate _expVars nodeNF._buildExpVars() # populate _expVars nodeN._buildExpVars() # populate _expVars nodeV._buildExpVars() # populate _expVars cmdNL = nodeNL.nodeDesc.buildCommandLine(nodeNL.chunks[0]) cmdNF = nodeNL.nodeDesc.buildCommandLine(nodeNF.chunks[0]) cmdN = nodeN.nodeDesc.buildCommandLine(nodeN.chunks[0]) cmdV = nodeV.nodeDesc.buildCommandLine(nodeV.chunks[0]) assert cmdNL assert cmdNF assert cmdN assert cmdV assert cmdNL == cmdNF assert cmdN == cmdNL assert cmdN == cmdV ================================================ FILE: tests/test_nodeDynamicOutputs.py ================================================ import pytest from meshroom.core import desc from meshroom.core import pluginManager from meshroom.core.exception import UnknownNodeTypeError from meshroom.core.graph import Graph, loadGraph from meshroom.core.plugins import NodePluginStatus from .utils import registerNodeDesc, unregisterNodeDesc class NodeWithDynamicOutputs(desc.Node): inputs = [ desc.BoolParam( name="boolInput", label="Bool Input", description="A boolean input.", value=False, ), desc.File( name="fileInput", label="File Input", description="A file input.", value="testFile", ), desc.StringParam( name="stringInput", label="String Input", description="A string input.", value="testString", ), desc.IntParam( name="intInput", label="Int Input", description="An integer input.", value=1, ), desc.FloatParam( name="floatInput", label="Float Input", description="A floating input.", value=5.0, ), ] outputs = [ desc.BoolParam( name="boolOutput", label="Bool Output", description="A boolean output.", value=None, ), desc.File( name="fileOutput", label="File Output", description="A file Output.", value=None, ), desc.StringParam( name="stringOutput", label="String Output", description="A string output.", value=None, ), desc.IntParam( name="intOutput", label="Int Output", description="An integer output.", value=None, ), desc.FloatParam( name="floatOutput", label="Float Output", description="A floating output.", value=None, ), ] def process(self, node): print("Processing NodeWithDynamicOutputs") node.boolOutput.value = not node.boolInput.value node.fileOutput.value = node.fileInput.value + ".ext" node.stringOutput.value = node.stringInput.value.upper() node.intOutput.value = node.intInput.value + 1 node.floatOutput.value = node.floatInput.value * 2.0 class InputNodeWithDynamicOutputs(desc.InputNode): inputs = [ desc.File( name="fileInput", label="File Input", description="A file input.", value="testFile", ), ] outputs = [ desc.File( name="fileOutput", label="File Output", description="A file Output.", value=None, ), ] class TestNodesWithDynamicOutputs: @classmethod def setup_class(cls): registerNodeDesc(NodeWithDynamicOutputs) @classmethod def teardown_class(cls): unregisterNodeDesc(NodeWithDynamicOutputs) def test_processWithDynamicOutputs(self, graphSavedOnDisk): graph: Graph = graphSavedOnDisk node = graph.addNewNode(NodeWithDynamicOutputs.__name__) # Execute the node to compute dynamic outputs node.process(inCurrentEnv=True) assert node.boolOutput.value assert node.fileOutput.value == "testFile.ext" assert node.stringOutput.value == "TESTSTRING" assert node.intOutput.value == 2 assert node.floatOutput.value == 10.0 def test_processWithDynamicOutputsNonDefaultInputs(self, graphSavedOnDisk): graph: Graph = graphSavedOnDisk node = graph.addNewNode(NodeWithDynamicOutputs.__name__) node.boolInput.value = True node.fileInput.value = "anotherTestFile" node.stringInput.value = "anotherTestString" node.intInput.value = 10 node.floatInput.value = 3.5 # Execute the node to compute dynamic outputs node.process(inCurrentEnv=True) assert not node.boolOutput.value assert node.fileOutput.value == "anotherTestFile.ext" assert node.stringOutput.value == "ANOTHERTESTSTRING" assert node.intOutput.value == 11 assert node.floatOutput.value == 7.0 def test_loadGraphWithUncomputedDynamicOutputs(self, graphSavedOnDisk): graph: Graph = graphSavedOnDisk node = graph.addNewNode(NodeWithDynamicOutputs.__name__) graph.save() loadedGraph = loadGraph(graph.filepath) loadedNode = loadedGraph.node(node.name) assert loadedNode assert loadedNode.boolOutput.value is None assert loadedNode.fileOutput.value is None assert loadedNode.stringOutput.value is None assert loadedNode.intOutput.value is None assert loadedNode.floatOutput.value is None def test_loadGraphWithComputedDynamicOutputs(self, graphSavedOnDisk): graph: Graph = graphSavedOnDisk node = graph.addNewNode(NodeWithDynamicOutputs.__name__) name = node.name graph.save() # Execute the node to compute dynamic outputs node.process(inCurrentEnv=True) # Check that the values have been correctly set assert node.boolOutput.value assert node.fileOutput.value == "testFile.ext" assert node.stringOutput.value == "TESTSTRING" assert node.intOutput.value == 2 assert node.floatOutput.value == 10.0 # Reload the graph from disk loadedGraph = loadGraph(graph.filepath) loadedNode = loadedGraph.node(name) # Check that the dynamic outputs have been correctly deserialized assert loadedNode assert loadedNode.boolOutput.value assert loadedNode.fileOutput.value == "testFile.ext" assert loadedNode.stringOutput.value == "TESTSTRING" assert loadedNode.intOutput.value == 2 assert loadedNode.floatOutput.value == 10.0 class TestInputNodeWithDynamicOutputs: def test_registerInputNodeWithDynamicOutputs(self): """ Force the registration of a node with an invalid description and check that its description is rejected and its status states it clearly. """ registerNodeDesc(InputNodeWithDynamicOutputs) # Check that the plugin has been correctly registered (there has been attempt to load it) assert pluginManager.isRegistered(InputNodeWithDynamicOutputs.__name__) # Check that the plugin's status is DESC_ERROR, since the node description is invalid # Additionally, the list of errors should include an error about having a dynamic output in an InputNode plugin = pluginManager.getRegisteredNodePlugin(InputNodeWithDynamicOutputs.__name__) assert plugin assert plugin.status == NodePluginStatus.DESC_ERROR assert len(plugin.errors) == 1 errType = plugin.errors[0][1] assert errType == desc.ValueTypeErrors.DYNAMIC_OUTPUT unregisterNodeDesc(InputNodeWithDynamicOutputs) def test_registerInputNodeWithDynamicOutputsV2(self): """" Check that an input node with dynamic outputs has not been registered because it is invalid. """ graph = Graph("") with pytest.raises(UnknownNodeTypeError): # InputDynamicOutputs is located in tests/nodes/test/InputDynamicOutputs.py # InputDynamicOutputs has the same description as InputNodeWithDynamicOutputs: had it been valid, it would # have been loaded and registered by the plugin manager at the upper level of the test suite. graph.addNewNode("InputDynamicOutputs") ================================================ FILE: tests/test_nodes.py ================================================ #!/usr/bin/env python # coding:utf-8 import os from pathlib import Path import tempfile from meshroom.core import desc, pluginManager, loadClassesNodes, initNodes from meshroom.core.graph import Graph, loadGraph from meshroom.core.plugins import Plugin from .utils import registerNodeDesc, unregisterNodeDesc, registeredNodeTypes class TestNodeInfo: plugin = None @classmethod def setup_class(cls): cls.folder = os.path.join(os.path.dirname(__file__), "plugins", "meshroom") package = "pluginC" cls.plugin = Plugin(package, cls.folder) nodes = loadClassesNodes(cls.folder, package) for node in nodes: cls.plugin.addNodePlugin(node) pluginManager.addPlugin(cls.plugin) @classmethod def teardown_class(cls): for node in cls.plugin.nodes.values(): pluginManager.unregisterNode(node) pluginManager.removePlugin(cls.plugin) cls.plugin = None def test_loadedPlugin(self): assert len(pluginManager.getPlugins()) >= 1 plugin = pluginManager.getPlugin("pluginC") assert plugin == self.plugin node = plugin.nodes["PluginCNodeA"] nodeType = node.nodeDescriptor g = Graph("") registerNodeDesc(nodeType) node = g.addNewNode(nodeType.__name__) nodeDocumentation = node.getDocumentation() assert nodeDocumentation == "PluginCNodeA" nodeInfo = {item["key"]: item["value"] for item in node.getNodeInfo()} assert nodeInfo["module"] == "pluginC.PluginCNodeA" pluginPath = os.path.join(self.folder, "pluginC", "PluginCNodeA.py") assert nodeInfo["modulePath"] == Path(pluginPath).as_posix() # modulePath seems to follow Linux convention assert nodeInfo["author"] == "testAuthor" assert nodeInfo["license"] == "no-license" assert nodeInfo["version"] == "1.0" unregisterNodeDesc(nodeType) class TestNodeVariables: plugin = None @classmethod def setup_class(cls): folder = os.path.join(os.path.dirname(__file__), "plugins", "meshroom") package = "pluginA" cls.plugin = Plugin(package, folder) nodes = loadClassesNodes(folder, package) for node in nodes: cls.plugin.addNodePlugin(node) pluginManager.addPlugin(cls.plugin) @classmethod def teardown_class(cls): for node in cls.plugin.nodes.values(): pluginManager.unregisterNode(node) pluginManager.removePlugin(cls.plugin) cls.plugin = None def test_staticVariables(self): g = Graph("") for nodeName in self.plugin.nodes.keys(): n = g.addNewNode(nodeName) assert nodeName == n._staticExpVars["nodeType"] assert n.sourceCodeFolder assert n.sourceCodeFolder == n._staticExpVars["nodeSourceCodeFolder"] self.plugin.nodes[nodeName].reload() assert nodeName == n._staticExpVars["nodeType"] assert n.sourceCodeFolder assert n.sourceCodeFolder == n._staticExpVars["nodeSourceCodeFolder"] def test_expVariables(self): g = Graph("") for nodeName in self.plugin.nodes.keys(): n = g.addNewNode(nodeName) assert n._expVars["uid"] == n._uid assert n.internalFolder assert n.internalFolder == n._expVars["nodeCacheFolder"] assert "node" in n._expVars assert n._expVars["node"] is n self.plugin.nodes[nodeName].reload() assert n._expVars["uid"] == n._uid assert n.internalFolder assert n.internalFolder == n._expVars["nodeCacheFolder"] assert "node" in n._expVars assert n._expVars["node"] is n class TestInitNode: plugin = None @classmethod def setup_class(cls): folder = os.path.join(os.path.dirname(__file__), "plugins", "meshroom") package = "pluginA" cls.plugin = Plugin(package, folder) nodes = loadClassesNodes(folder, package) for node in nodes: cls.plugin.addNodePlugin(node) pluginManager.addPlugin(cls.plugin) @classmethod def teardown_class(cls): for node in cls.plugin.nodes.values(): pluginManager.unregisterNode(node) pluginManager.removePlugin(cls.plugin) cls.plugin = None def test_initNode(self): g = Graph("") node = g.addNewNode("PluginAInputInitNode") # Check that the init node is correctly detected initNodes = g.findInitNodes() assert len(initNodes) == 1 and node in initNodes # Check that the init node's initialize method has been set inputs = ["/path/to/file", "/path/to/file/2"] node.nodeDesc.initialize(node, inputs, None) assert node.input.value == inputs[0] class TestBackdropNode: loadedPlugins = pluginManager.getPlugins() @classmethod def setup_class(cls): initNodes() @classmethod def teardown_class(cls): for plugin in pluginManager.getPlugins(): if plugin not in cls.loadedPlugins: for node in plugin.nodes.values(): pluginManager.unregisterNode(node) pluginManager.removePlugin(plugin) def test_backdropNode(self): """ Test that a backdrop node can be added to a graph with its expected default values. """ g = Graph("Default Backdrop node") backdrop = g.addNewNode("Backdrop") # Check that the default values for backdrop are as expected assert backdrop is not None assert backdrop.nodeWidth == 600 assert backdrop.nodeHeight == 400 assert backdrop.fontSize == 12 assert backdrop.fontColor == "" assert backdrop.color == "" assert backdrop.comment == "" # Add a non-backdrop node and check that its default values are not backdrop's ones node = g.addNewNode("CopyFiles") assert node is not None assert node.nodeWidth == 0 assert node.nodeHeight == 0 assert node.fontSize == 0 assert node.fontColor == "" assert node.color == "" assert node.comment == "" def test_backdropNode_customAttributes(self): """ Test that a backdrop node's attributes can be correctly updated. """ g = Graph("Backdrop node with custom values") backdrop = g.addNewNode("Backdrop") # Set custom values for backdrop and assert the properties are correctly updated width = backdrop.internalAttribute("nodeWidth") width.value = 400 assert backdrop.nodeWidth == 400 height = backdrop.internalAttribute("nodeHeight") height.value = 200 assert backdrop.nodeHeight == 200 fontSize = backdrop.internalAttribute("fontSize") fontSize.value = 10 assert backdrop.fontSize == 10 fontColor = backdrop.internalAttribute("fontColor") fontColor.value = "#00FF00" assert backdrop.fontColor == "#00FF00" color = backdrop.internalAttribute("color") color.value = "#FF0000" assert backdrop.color == "#FF0000" comment = backdrop.internalAttribute("comment") comment.value = "hello world" assert backdrop.comment == "hello world" def test_backdropNode_defaultSerialization(self): """ Test that a backdrop node with default values is correctly serialized and deserialized. """ g = Graph("Backdrop node default serialization") backdrop = g.addNewNode("Backdrop") # Save the graph in a file graphFile = os.path.join(tempfile.mkdtemp(), "test_backdrop_serialization.mg") g.save(graphFile) # Reload the graph and check the values for the backdrop node are the default ones g = loadGraph(graphFile) backdrop = g.node("Backdrop_1") assert backdrop is not None assert backdrop.nodeWidth == 600 assert backdrop.nodeHeight == 400 assert backdrop.fontSize == 12 assert backdrop.fontColor == "" assert backdrop.color == "" assert backdrop.comment == "" def test_backdropNode_customSerialization(self): """ Test that a backdrop node with custom values is correctly serialized and deserialized. """ g = Graph("Backdrop node custom serialization") backdrop = g.addNewNode("Backdrop") # Set custom values for backdrop width = backdrop.internalAttribute("nodeWidth") width.value = 400 height = backdrop.internalAttribute("nodeHeight") height.value = 200 fontSize = backdrop.internalAttribute("fontSize") fontSize.value = 10 fontColor = backdrop.internalAttribute("fontColor") fontColor.value = "#00FF00" color = backdrop.internalAttribute("color") color.value = "#FF0000" comment = backdrop.internalAttribute("comment") comment.value = "hello world" # Save the graph in a file graphFile = os.path.join(tempfile.mkdtemp(), "test_backdrop_serialization.mg") g.save(graphFile) # Reload the graph and check the values for the backdrop node are the default ones g = loadGraph(graphFile) backdrop = g.node("Backdrop_1") assert backdrop is not None assert backdrop.nodeWidth == 400 assert backdrop.nodeHeight == 200 assert backdrop.fontSize == 10 assert backdrop.fontColor == "#00FF00" assert backdrop.color == "#FF0000" assert backdrop.comment == "hello world" class TestResourceLevels: """ Test that cpu, gpu, and ram descriptor attributes support both static Level values and callables. """ def test_staticResourceLevels(self): """ Test that static Level values are returned as-is. """ class StaticLevelNode(desc.Node): cpu = desc.Level.INTENSIVE gpu = desc.Level.NONE ram = desc.Level.EXTREME inputs = [] outputs = [] with registeredNodeTypes([StaticLevelNode]): g = Graph("") node = g.addNewNode("StaticLevelNode") assert node.cpu == desc.Level.INTENSIVE assert node.gpu == desc.Level.NONE assert node.ram == desc.Level.EXTREME def test_callableResourceLevels(self): """ Test that callable cpu/gpu/ram values are called with the node instance. """ class CallableLevelNode(desc.Node): cpu = lambda node: desc.Level.INTENSIVE if node.attribute("useMoreCpu").value else desc.Level.NORMAL gpu = lambda node: desc.Level.NORMAL if node.attribute("useGpu").value else desc.Level.NONE ram = lambda node: desc.Level.EXTREME if node.attribute("useMuchRam").value else desc.Level.NORMAL inputs = [ desc.BoolParam(name="useMoreCpu", label="", description="", value=False, invalidate=False), desc.BoolParam(name="useGpu", label="", description="", value=False, invalidate=False), desc.BoolParam(name="useMuchRam", label="", description="", value=False, invalidate=False), ] outputs = [] with registeredNodeTypes([CallableLevelNode]): g = Graph("") node = g.addNewNode("CallableLevelNode") # Default values: all False assert node.cpu == desc.Level.NORMAL assert node.gpu == desc.Level.NONE assert node.ram == desc.Level.NORMAL # Change attribute values node.attribute("useMoreCpu").value = True assert node.cpu == desc.Level.INTENSIVE node.attribute("useGpu").value = True assert node.gpu == desc.Level.NORMAL node.attribute("useMuchRam").value = True assert node.ram == desc.Level.EXTREME def test_mixedResourceLevels(self): """ Test a node mixing static and callable resource level attributes. """ class MixedLevelNode(desc.Node): cpu = desc.Level.NORMAL # static gpu = lambda node: desc.Level.INTENSIVE if node.attribute("useGpu").value else desc.Level.NONE # callable ram = desc.Level.EXTREME # static inputs = [ desc.BoolParam(name="useGpu", label="", description="", value=False, invalidate=False), ] outputs = [] with registeredNodeTypes([MixedLevelNode]): g = Graph("") node = g.addNewNode("MixedLevelNode") assert node.cpu == desc.Level.NORMAL assert node.gpu == desc.Level.NONE assert node.ram == desc.Level.EXTREME node.attribute("useGpu").value = True assert node.gpu == desc.Level.INTENSIVE class TestNodeColor: """ Test that the color descriptor attribute can be defined on a node class and overridden. """ def test_defaultColor(self): """ Test that the default color for a node with no color defined is empty string. """ class NoColorNode(desc.Node): inputs = [] outputs = [] with registeredNodeTypes([NoColorNode]): g = Graph("") node = g.addNewNode("NoColorNode") assert node.color == "" def test_descriptorColor(self): """ Test that a node class with a color defined returns that color when no instance color is set. """ class ColoredNode(desc.Node): color = "#FF0000" inputs = [] outputs = [] with registeredNodeTypes([ColoredNode]): g = Graph("") node = g.addNewNode("ColoredNode") # The node has no instance-specific color, so it should return the descriptor color assert node.color == "#FF0000" def test_instanceColorOverridesDescriptorColor(self): """ Test that an instance-specific color overrides the descriptor color. """ class ColoredNode2(desc.Node): color = "#FF0000" inputs = [] outputs = [] with registeredNodeTypes([ColoredNode2]): g = Graph("") node = g.addNewNode("ColoredNode2") # Override with instance color node.internalAttribute("color").value = "#00FF00" assert node.color == "#00FF00" def test_resetToDefaultRestoresDescriptorColor(self): """ Test that resetting the color attribute to its default restores the descriptor color. """ class ColoredNode3(desc.Node): color = "#FF0000" inputs = [] outputs = [] with registeredNodeTypes([ColoredNode3]): g = Graph("") node = g.addNewNode("ColoredNode3") # Set an instance color node.internalAttribute("color").value = "#00FF00" assert node.color == "#00FF00" # Resetting to default should restore the descriptor color node.internalAttribute("color").resetToDefaultValue() assert node.color == "#FF0000" class TestNodeSizeLambda: """Tests for the node size evaluation with single-argument lambda (`lambda node: ...`).""" def test_size_lambda_single_arg(self): """size defined as `lambda node: ...` should be evaluated with the node instance.""" class NodeWithLambdaSize(desc.Node): inputs = [ desc.IntParam( name="sizeInput", label="Size Input", description="Defines the node size.", value=5, range=(0, 100, 1), ), ] outputs = [] size = lambda node: node.sizeInput.value with registeredNodeTypes([NodeWithLambdaSize]): g = Graph("") node = g.addNewNode("NodeWithLambdaSize") assert node.evaluateSize() == 5 node.sizeInput.value = 10 assert node.evaluateSize() == 10 def test_size_static_node_size(self): """size defined as StaticNodeSize should still be evaluated correctly.""" class NodeWithStaticSize(desc.Node): inputs = [] outputs = [] size = desc.StaticNodeSize(7) with registeredNodeTypes([NodeWithStaticSize]): g = Graph("") node = g.addNewNode("NodeWithStaticSize") assert node.evaluateSize() == 7 def test_size_dynamic_node_size(self): """size defined as DynamicNodeSize should return the value of the referenced IntParam.""" class NodeWithDynamicSize(desc.Node): inputs = [ desc.IntParam( name="count", label="Count", description="Number of items.", value=4, range=(0, 100, 1), ), ] outputs = [] size = desc.DynamicNodeSize("count") with registeredNodeTypes([NodeWithDynamicSize]): g = Graph("") node = g.addNewNode("NodeWithDynamicSize") assert node.evaluateSize() == 4 node.count.value = 12 assert node.evaluateSize() == 12 def test_size_custom_function(self): """size defined as a named function should be called with the node instance.""" def customSizeFunction(node): return node.itemCount.value * 2 class NodeWithCustomFunctionSize(desc.Node): inputs = [ desc.IntParam( name="itemCount", label="Item Count", description="Number of items.", value=3, range=(0, 100, 1), ), ] outputs = [] size = customSizeFunction with registeredNodeTypes([NodeWithCustomFunctionSize]): g = Graph("") node = g.addNewNode("NodeWithCustomFunctionSize") assert node.evaluateSize() == 6 node.itemCount.value = 5 assert node.evaluateSize() == 10 def test_size_custom_callable_class(self): """size defined as an instance of a class with __call__ should be called with the node instance.""" class CustomSizeComputer: def __call__(self, node): return node.itemCount.value + 1 class NodeWithCustomCallableSize(desc.Node): inputs = [ desc.IntParam( name="itemCount", label="Item Count", description="Number of items.", value=7, range=(0, 100, 1), ), ] outputs = [] size = CustomSizeComputer() with registeredNodeTypes([NodeWithCustomCallableSize]): g = Graph("") node = g.addNewNode("NodeWithCustomCallableSize") assert node.evaluateSize() == 8 node.itemCount.value = 9 assert node.evaluateSize() == 10 ================================================ FILE: tests/test_pipeline.py ================================================ #!/usr/bin/env python # coding:utf-8 import os import tempfile import meshroom.multiview from meshroom.core.graph import loadGraph from meshroom.core.node import Node def test_pipeline(): meshroom.core.initNodes() meshroom.core.initPipelines() graph1InputFiles = ["/non/existing/file1", "/non/existing/file2"] graph1 = loadGraph(meshroom.core.pipelineTemplates["appendTextAndFiles"]) graph1.name = "graph1" graph1AppendText1 = graph1.node("AppendText_1") graph1AppendText1.input.value = graph1InputFiles[0] graph1AppendText2 = graph1.node("AppendText_2") graph1AppendText2.input.value = graph1InputFiles[1] assert graph1.findNode("AppendFiles").input.value == graph1AppendText1.output.value assert graph1.findNode("AppendFiles").input2.value == graph1AppendText2.output.value assert graph1.findNode("AppendFiles").input3.value == graph1InputFiles[0] assert graph1.findNode("AppendFiles").input4.value == graph1InputFiles[1] assert not graph1AppendText1.input.isDefault assert graph1AppendText2.input.getPrimitiveValue() == graph1InputFiles[1] graph2InputFiles = ["/non/existing/file", ""] graph2 = loadGraph(meshroom.core.pipelineTemplates["appendTextAndFiles"]) graph2.name = "graph2" graph2AppendText1 = graph2.node("AppendText_1") graph2AppendText1.input.value = graph2InputFiles[0] graph2AppendText2 = graph2.node("AppendText_2") graph2AppendText2.input.value = graph2InputFiles[1] # Ensure that all output UIDs are different as the input is different: # graph1 != graph2 for node in graph1.nodes: otherNode = graph2.node(node.name) for key, attr in node.attributes.items(): if attr.isOutput and attr.enabled: otherAttr = otherNode.attribute(key) assert attr.uid() != otherAttr.uid() # Test serialization/deserialization on both graphs for graph in [graph1, graph2]: filename = tempfile.mktemp() graph.save(filename) loadedGraph = loadGraph(filename) os.remove(filename) # Check that all nodes have been properly de-serialized # - Same node set assert sorted([n.name for n in loadedGraph.nodes]) == sorted([n.name for n in graph.nodes]) # - No compatibility issues assert all(isinstance(n, Node) for n in loadedGraph.nodes) # - Same UIDs for every node assert sorted([n._uid for n in loadedGraph.nodes]) == sorted([n._uid for n in graph.nodes]) # Graph 2b, set with identical parameters as graph 2 graph2b = loadGraph(meshroom.core.pipelineTemplates["appendTextAndFiles"]) graph2b.name = "graph2b" graph2bAppendText1 = graph2b.node("AppendText_1") graph2bAppendText1.input.value = graph2InputFiles[0] graph2bAppendText2 = graph2b.node("AppendText_2") graph2bAppendText2.input.value = graph2InputFiles[1] # Ensure that graph2 == graph2b nodes, edges = graph2.dfsOnFinish() for node in nodes: otherNode = graph2b.node(node.name) for key, attr in node.attributes.items(): otherAttr = otherNode.attribute(key) if attr.isOutput and attr.enabled: assert attr.uid() == otherAttr.uid() else: assert attr.uid() == otherAttr.uid() ================================================ FILE: tests/test_plugins.py ================================================ # coding:utf-8 from meshroom.core import pluginManager, loadClassesNodes from meshroom.core.plugins import NodePluginStatus, Plugin from .utils import overrideOsEnvironmentVariables, registeredPlugins from pathlib import Path import os import time class TestPluginWithValidNodesOnly: plugin = None @classmethod def setup_class(cls): folder = os.path.join(os.path.dirname(__file__), "plugins", "meshroom") package = "pluginA" cls.plugin = Plugin(package, folder) nodes = loadClassesNodes(folder, package) for node in nodes: cls.plugin.addNodePlugin(node) pluginManager.addPlugin(cls.plugin) @classmethod def teardown_class(cls): for node in cls.plugin.nodes.values(): pluginManager.unregisterNode(node) pluginManager.removePlugin(cls.plugin) cls.plugin = None def test_loadedPlugin(self): # Assert that there are loaded plugins, and that "pluginA" is one of them assert len(pluginManager.getPlugins()) >= 1 plugin = pluginManager.getPlugin("pluginA") assert plugin == self.plugin assert str(plugin.path) == os.path.join(os.path.dirname(__file__), "plugins", "meshroom") # Assert that the nodes of pluginA have been successfully registered assert len(pluginManager.getRegisteredNodePlugins()) >= 2 for nodeName, nodePlugin in plugin.nodes.items(): assert nodePlugin.status == NodePluginStatus.LOADED assert pluginManager.isRegistered(nodeName) # Assert the template has been loaded assert len(plugin.templates) == 1 name = list(plugin.templates.keys())[0] assert name == "sharedTemplate" assert plugin.templates[name] == os.path.join(str(plugin.path), "sharedTemplate.mg") def test_unloadPlugin(self): plugin = pluginManager.getPlugin("pluginA") assert plugin == self.plugin # Unload the plugin without unregistering the nodes pluginManager.removePlugin(plugin, unregisterNodePlugins=False) # Assert the plugin is not loaded anymore assert pluginManager.getPlugin(plugin.name) is None # Assert the nodes are still registered and belong to an unloaded plugin for nodeName, nodePlugin in plugin.nodes.items(): assert nodePlugin.status == NodePluginStatus.LOADED assert pluginManager.isRegistered(nodeName) assert pluginManager.belongsToPlugin(nodeName) is None # Re-add the plugin pluginManager.addPlugin(plugin, registerNodePlugins=False) assert pluginManager.getPlugin(plugin.name) # Unload the plugin with a full unregistration of the nodes pluginManager.removePlugin(plugin) # Assert the plugin is not loaded anymore assert pluginManager.getPlugin(plugin.name) is None # Assert the nodes have been successfully unregistered for nodeName, nodePlugin in plugin.nodes.items(): assert nodePlugin.status == NodePluginStatus.NOT_LOADED assert not pluginManager.isRegistered(nodeName) # Re-add the plugin and re-register the nodes pluginManager.addPlugin(plugin) assert pluginManager.getPlugin(plugin.name) for nodeName, nodePlugin in plugin.nodes.items(): assert nodePlugin.status == NodePluginStatus.LOADED assert pluginManager.isRegistered(nodeName) def test_updateRegisteredNodes(self): nbRegisteredNodes = len(pluginManager.getRegisteredNodePlugins()) plugin = pluginManager.getPlugin("pluginA") assert plugin == self.plugin nodeA = pluginManager.getRegisteredNodePlugin("PluginANodeA") nodeAName = nodeA.nodeDescriptor.__name__ # Unregister a node assert nodeA pluginManager.unregisterNode(nodeA) # Check that the node has been fully unregistered: # - its status is "NOT_LOADED" # - it is still part of pluginA # - it is not in the list of registered plugins anymore (and returns None when requested) assert nodeA.status == NodePluginStatus.NOT_LOADED assert plugin.containsNodePlugin(nodeAName) assert nodeA.plugin == plugin assert pluginManager.getRegisteredNodePlugin(nodeAName) is None assert nodeAName not in pluginManager.getRegisteredNodePlugins() assert len(pluginManager.getRegisteredNodePlugins()) == nbRegisteredNodes - 1 # Re-register the node pluginManager.registerNode(nodeA) assert nodeA.status == NodePluginStatus.LOADED assert pluginManager.getRegisteredNodePlugin(nodeAName) assert len(pluginManager.getRegisteredNodePlugins()) == nbRegisteredNodes class TestPluginWithInvalidNodes: plugin = None @classmethod def setup_class(cls): folder = os.path.join(os.path.dirname(__file__), "plugins", "meshroom") package = "pluginB" cls.plugin = Plugin(package, folder) nodes = loadClassesNodes(folder, package) for node in nodes: cls.plugin.addNodePlugin(node) pluginManager.addPlugin(cls.plugin) @classmethod def teardown_class(cls): for node in cls.plugin.nodes.values(): pluginManager.unregisterNode(node) pluginManager.removePlugin(cls.plugin) cls.plugin = None def test_loadedPlugin(self): # Assert that there are loaded plugins, and that "pluginB" is one of them assert len(pluginManager.getPlugins()) >= 1 plugin = pluginManager.getPlugin("pluginB") assert plugin == self.plugin assert str(plugin.path) == os.path.join(os.path.dirname(__file__), "plugins", "meshroom") # Assert that PluginBNodeA is successfully registered assert pluginManager.isRegistered("PluginBNodeA") assert plugin.nodes["PluginBNodeA"].status == NodePluginStatus.LOADED assert plugin.nodes["PluginBNodeA"].plugin == plugin # Assert that PluginBNodeB has not been registered (description error) assert not pluginManager.isRegistered("PluginBNodeB") assert plugin.nodes["PluginBNodeB"].status == NodePluginStatus.DESC_ERROR assert plugin.nodes["PluginBNodeB"].plugin == plugin # Assert the template has been loaded assert len(plugin.templates) == 1 name = list(plugin.templates.keys())[0] assert name == "sharedTemplate" assert plugin.templates[name] == os.path.join(str(plugin.path), "sharedTemplate.mg") def test_reloadNodePluginInvalidDescrpition(self): plugin = pluginManager.getPlugin("pluginB") assert plugin == self.plugin node = plugin.nodes["PluginBNodeB"] nodeName = node.nodeDescriptor.__name__ # Check that the node has not been registered assert node.status == NodePluginStatus.DESC_ERROR assert not pluginManager.isRegistered(nodeName) # Check that the node cannot be registered pluginManager.registerNode(node) assert not pluginManager.isRegistered(nodeName) # Replace directly in the node file the line that fails the validation # on the description with a line that will pass originalFileContent = None with open(node.path, "r") as f: originalFileContent = f.read() replaceFileContent = originalFileContent.replace('"not an integer"', '1') with open(node.path, "w") as f: f.write(replaceFileContent) # Reload the node and assert it is valid node.reload() assert node.status == NodePluginStatus.NOT_LOADED # Attempt to register node plugin pluginManager.registerNode(node) assert pluginManager.isRegistered(nodeName) # Reload the node again without any change node.reload() assert pluginManager.isRegistered(nodeName) # Hack to ensure that the timestamp of the file will be different after being rewritten # Without it, on some systems, the operation is too fast and the timestamp does not change, # cause the test to fail time.sleep(0.1) # Restore the node file to its original state (with a description error) with open(node.path, "w") as f: f.write(originalFileContent) timestampOr2 = os.path.getmtime(node.path) print(f"New timestamp: {timestampOr2}") print(os.stat(node.path)) # Reload the node and assert it is invalid while still registered node.reload() assert node.status == NodePluginStatus.DESC_ERROR assert pluginManager.isRegistered(nodeName) # Unregister it pluginManager.unregisterNode(node) assert node.status == NodePluginStatus.DESC_ERROR # Not NOT_LOADED assert not pluginManager.isRegistered(nodeName) def test_reloadNodePluginSyntaxError(self): plugin = pluginManager.getPlugin("pluginB") assert plugin == self.plugin node = plugin.nodes["PluginBNodeA"] nodeName = node.nodeDescriptor.__name__ # Check that the node has been registered assert node.status == NodePluginStatus.LOADED assert pluginManager.isRegistered(nodeName) # Introduce a syntax error in the description originalFileContent = None with open(node.path, "r") as f: originalFileContent = f.read() replaceFileContent = originalFileContent.replace('name="input",', 'name="input"') with open(node.path, "w") as f: f.write(replaceFileContent) # Reload the node and assert it is invalid but still registered node.reload() assert node.status == NodePluginStatus.DESC_ERROR assert pluginManager.isRegistered(nodeName) # Restore the node file to its original state (with a description error) with open(node.path, "w") as f: f.write(originalFileContent) # Assert the status is correct and the node is still registered node.reload() assert node.status == NodePluginStatus.NOT_LOADED assert pluginManager.isRegistered(nodeName) class TestPluginsConfiguration: CONFIG_PATH = ("CONFIG_PATH", "sharedTemplate.mg", "config.json") ERRONEOUS_CONFIG_PATH = ("ERRONEOUS_CONFIG_PATH", "erroneous_path", "not_erroneous_path") CONFIG_STRING = ("CONFIG_STRING", "configFile", "notConfigFile") CONFIG_KEYS = [CONFIG_PATH[0], ERRONEOUS_CONFIG_PATH[0], CONFIG_STRING[0]] def test_loadedConfig(self): # Check that the config.json file for the plugins in the "plugins" directory is # correctly loaded folder = os.path.join(os.path.dirname(__file__), "plugins") with registeredPlugins(folder): plugin = pluginManager.getPlugin("pluginA") assert plugin # Check that the config file has been properly loaded config = plugin.configEnv configFullEnv = plugin.configFullEnv assert len(config) == 3, "The configuration file contains exactly 3 keys." assert len(configFullEnv) >= len(os.environ) and \ len(configFullEnv) == len(os.environ) + len(config), \ "The configuration environment should have the same number of keys as " \ "os.environ and the configuration file" # Check that all the keys have been properly read assert list(config.keys()) == self.CONFIG_KEYS # Check that the valid path has been correctly read, resolved and set assert configFullEnv[self.CONFIG_PATH[0]] == config[self.CONFIG_PATH[0]] assert configFullEnv[self.CONFIG_PATH[0]] == Path( os.path.join(plugin.path, self.CONFIG_PATH[1])).resolve().as_posix() # Check that the invalid path has been read, unresolved, and set assert configFullEnv[self.ERRONEOUS_CONFIG_PATH[0]] == self.ERRONEOUS_CONFIG_PATH[1] assert config[self.ERRONEOUS_CONFIG_PATH[0]] == self.ERRONEOUS_CONFIG_PATH[1] # Check that the string has been correctly read and set assert configFullEnv[self.CONFIG_STRING[0]] == self.CONFIG_STRING[1] assert config[self.CONFIG_STRING[0]] == self.CONFIG_STRING[1] def test_loadedConfigWithOnlyExistingKeys(self): # Set the keys from the config file in the current environment environment = { self.CONFIG_PATH[0]: self.CONFIG_PATH[2], self.ERRONEOUS_CONFIG_PATH[0]: self.ERRONEOUS_CONFIG_PATH[2], self.CONFIG_STRING[0]: self.CONFIG_STRING[2] } folder = os.path.join(os.path.dirname(__file__), "plugins") with (overrideOsEnvironmentVariables(environment), registeredPlugins(folder)): plugin = pluginManager.getPlugin("pluginA") assert plugin # Check that the config file has been properly loaded and read # Environment variables that are already set should not have any effect on that # reading of values config = plugin.configEnv assert len(config) == 3 assert list(config.keys()) == self.CONFIG_KEYS assert config[self.CONFIG_PATH[0]] == Path( os.path.join(plugin.path, self.CONFIG_PATH[1])).resolve().as_posix() assert config[self.ERRONEOUS_CONFIG_PATH[0]] == self.ERRONEOUS_CONFIG_PATH[1] assert config[self.CONFIG_STRING[0]] == self.CONFIG_STRING[1] # Check that the values of the configuration file are not taking precedence over # those in the environment configFullEnv = plugin.configFullEnv assert all(key in configFullEnv for key in config.keys()) assert config[self.CONFIG_PATH[0]] != self.CONFIG_PATH[2] assert configFullEnv[self.CONFIG_PATH[0]] == self.CONFIG_PATH[2] assert config[self.ERRONEOUS_CONFIG_PATH[0]] != self.ERRONEOUS_CONFIG_PATH[2] assert configFullEnv[self.ERRONEOUS_CONFIG_PATH[0]] == self.ERRONEOUS_CONFIG_PATH[2] assert config[self.CONFIG_STRING[0]] != self.CONFIG_STRING[2] assert configFullEnv[self.CONFIG_STRING[0]] == self.CONFIG_STRING[2] def test_loadedConfigWithSomeExistingKeys(self): # Set some keys from the config file in the current environment environment = { self.ERRONEOUS_CONFIG_PATH[0]: self.ERRONEOUS_CONFIG_PATH[2], self.CONFIG_STRING[0]: self.CONFIG_STRING[2] } folder = os.path.join(os.path.dirname(__file__), "plugins") with (overrideOsEnvironmentVariables(environment), registeredPlugins(folder)): plugin = pluginManager.getPlugin("pluginA") assert plugin # Check that the config file has been properly loaded and read # Environment variables that are already set should not have any effect on that # reading of values config = plugin.configEnv assert len(config) == 3 assert list(config.keys()) == self.CONFIG_KEYS assert config[self.CONFIG_PATH[0]] == Path( os.path.join(plugin.path, self.CONFIG_PATH[1])).resolve().as_posix() assert config[self.ERRONEOUS_CONFIG_PATH[0]] == self.ERRONEOUS_CONFIG_PATH[1] assert config[self.CONFIG_STRING[0]] == self.CONFIG_STRING[1] # Check that the values of the configuration file are not taking precedence over # those in the environment configFullEnv = plugin.configFullEnv assert all(key in configFullEnv for key in config.keys()) assert config[self.CONFIG_PATH[0]] == Path(os.path.join( plugin.path, self.CONFIG_PATH[1])).resolve().as_posix() assert configFullEnv[self.CONFIG_PATH[0]] == Path(os.path.join( plugin.path, self.CONFIG_PATH[1])).resolve().as_posix() assert config[self.ERRONEOUS_CONFIG_PATH[0]] != self.ERRONEOUS_CONFIG_PATH[2] assert configFullEnv[self.ERRONEOUS_CONFIG_PATH[0]] == self.ERRONEOUS_CONFIG_PATH[2] assert config[self.CONFIG_STRING[0]] != self.CONFIG_STRING[2] assert configFullEnv[self.CONFIG_STRING[0]] == self.CONFIG_STRING[2] ================================================ FILE: tests/test_submit.py ================================================ # coding:utf-8 """ This test aims to replicate toe process on node submission """ import os import time from sys import platform from .utils import registerNodeDesc import meshroom from meshroom.core import pluginManager, loadClassesNodes, loadSubmitters, registerSubmitter, meshroomFolder from meshroom.core.graph import Graph from meshroom.core.plugins import Plugin from meshroom.core.node import Node, Status from meshroom.core.submitter import jobManager from meshroom.submitters.localFarmSubmitter import LocalFarmSubmitter, LocalFarmJob from localfarm.localFarmLauncher import FarmLauncher IS_LINUX = (platform == "linux" or platform == "linux2") def get_submitter() -> LocalFarmSubmitter: for sName, s in meshroom.core.submitters.items(): if sName == "LocalFarm": return s raise RuntimeError("LocalFarm submitter not found") def getJobEnv(): """ Required to have meshroom recognize plugins that were created here """ pluginFolder = os.path.join(os.path.dirname(__file__), "plugins") return { "MESHROOM_PLUGINS_PATH": pluginFolder } def waitForNodeCompletion(job: LocalFarmJob, node: Node, timeout=25): """ Wait for a node to complete processing """ print(f"Waiting for node {node.name} to complete...") startTime = time.time() while True: node.updateStatusFromCache() nodeStatus = node.getGlobalStatus() if nodeStatus not in (Status.SUBMITTED, Status.RUNNING): print(f"Node status switched to {nodeStatus}") return # Check for job error err = job.getJobErrors() if err: raise RuntimeError(f"Job encountered an error: {err}") if time.time() - startTime > timeout: raise TimeoutError(f"Node {node.name} did not complete within {timeout} seconds") time.sleep(1) def processSubmit(node: Node, graph, tmp_path): """ Actual function that test the submit process """ # Save graph tmp_path = str(tmp_path) graph.save(os.path.join(tmp_path, "graph.mg")) # Prepare all chunks node.initStatusOnSubmit() # Start farm farmLauncher = FarmLauncher(tmp_path) farmLauncher.start() time.sleep(1) error = None try: print(f"submit {node}") submitter = get_submitter() submitter.setFarmPath(tmp_path) submitter.setJobEnv(getJobEnv()) nodesToProcess, edgesToProcess = [node], [] # Update nodes status for node in nodesToProcess: node.initStatusOnSubmit() # Update monitored to make sure meshroom knows when task status change graph.updateMonitoredFiles() assert node.getGlobalStatus() == Status.SUBMITTED res = submitter.submit(nodesToProcess, edgesToProcess, graph.filepath, submitLabel="TestSubmit") assert res is not None, "Submitter returned no job" assert res.__class__.__name__ == "LocalFarmJob", "Submitted job is not a LocalFarmJob" jobManager.addJob(res, nodesToProcess) waitForNodeCompletion(res, node) except Exception as e: error = e finally: farmLauncher.stop() if error: raise error else: farmLauncher.clean() class TestNodeSubmit: __test__ = IS_LINUX @classmethod def setup_class(cls): # meshroom.core.initSubmitters() submitters = loadSubmitters(meshroomFolder, "submitters") for submitter in submitters: registerSubmitter(submitter()) cls.folder = os.path.join(os.path.dirname(__file__), "plugins", "meshroom") package = "pluginSubmitter" cls.plugin = Plugin(package, cls.folder) nodes = loadClassesNodes(cls.folder, package) for node in nodes: cls.plugin.addNodePlugin(node) pluginManager.addPlugin(cls.plugin) @classmethod def teardown_class(cls): for node in cls.plugin.nodes.values(): pluginManager.unregisterNode(node) pluginManager.removePlugin(cls.plugin) cls.plugin = None def setupNode(self, graph, name): plugin = pluginManager.getPlugin("pluginSubmitter") node = plugin.nodes[name] nodeType = node.nodeDescriptor registerNodeDesc(nodeType) node = graph.addNewNode(nodeType.__name__) return node def test_submitNoParallel(self, tmp_path): graph = Graph("") graph._cacheDir = os.path.join(tmp_path, "cache") node = self.setupNode(graph, "PluginSubmitterA") # Submit processSubmit(node, graph, tmp_path) def test_submitStaticSize(self, tmp_path): graph = Graph("") graph._cacheDir = os.path.join(tmp_path, "cache") node = self.setupNode(graph, "PluginSubmitterB") # Submit processSubmit(node, graph, tmp_path) def test_submitDynamicSize(self, tmp_path): graph = Graph("") graph._cacheDir = os.path.join(tmp_path, "cache") node = self.setupNode(graph, "PluginSubmitterC") # Submit processSubmit(node, graph, tmp_path) ================================================ FILE: tests/utils.py ================================================ from contextlib import contextmanager from unittest.mock import patch import meshroom from meshroom.core import desc, pluginManager, loadPluginFolder from meshroom.core.plugins import NodePlugin, NodePluginStatus import os @contextmanager def registeredNodeTypes(nodeTypes: list[desc.Node]): nodePluginsList = {} for nodeType in nodeTypes: nodePlugin = NodePlugin(nodeType) pluginManager.registerNode(nodePlugin) nodePluginsList[nodeType] = nodePlugin yield for nodeType in nodeTypes: pluginManager.unregisterNode(nodePluginsList[nodeType]) @contextmanager def overrideNodeTypeVersion(nodeType: desc.Node, version: str): """ Helper context manager to override the version of a given node type. """ unpatchedFunc = meshroom.core.nodeVersion with patch.object( meshroom.core, "nodeVersion", side_effect=lambda type: version if type is nodeType else unpatchedFunc(type), ): yield def registerNodeDesc(nodeDesc: desc.Node): name = nodeDesc.__name__ if not pluginManager.isRegistered(name): pluginManager._nodePlugins[name] = NodePlugin(nodeDesc) def unregisterNodeDesc(nodeDesc: desc.Node): name = nodeDesc.__name__ if pluginManager.isRegistered(name): del pluginManager._nodePlugins[name] @contextmanager def registeredPlugins(folder: str): plugins = loadPluginFolder(folder) yield for plugin in plugins: pluginManager.removePlugin(plugin) @contextmanager def overrideOsEnvironmentVariables(envVariables: dict): with patch.dict(os.environ, envVariables, clear=False): yield