[
  {
    "path": ".codecov.yml",
    "content": "# Codecov configuration for Meshroom\n# This configuration prevents CI from failing due to small coverage decreases\n# while maintaining quality standards\n\ncodecov:\n  # Require CI to pass before processing results\n  require_ci_to_pass: yes\n\ncoverage:\n  status:\n    project:\n      default:\n        # Allow coverage to drop by up to 1% without failing\n        threshold: 1%\n        # Set a minimum target coverage (adjust based on your current ~77%)\n        target: 75%\n        # Only check coverage on lines that are coverable\n        base: auto\n        # Ignore if no coverage files are uploaded\n        if_no_uploads: error\n        # Don't fail if coverage file not found\n        if_not_found: success\n        # Fail if CI failed\n        if_ci_failed: error\n        # Only run on these branches\n        branches:\n          - develop\n          - master\n          - main\n\n    patch:\n      default:\n        # For new code, allow 2% threshold since new features may need refactoring\n        threshold: 2%\n        # New code should aim for 70% coverage (lower than overall project)\n        target: 70%\n        # Only run patch coverage on pull requests\n        only_pulls: true\n        if_no_uploads: error\n        if_not_found: success\n        if_ci_failed: error\n\n  precision: 2\n  round: down\n  range: \"70...95\"\n\n# Ignore certain files/directories that should not affect coverage\nignore:\n  - \"setup.py\"\n  - \"docs/\"\n  - \"scripts/\"\n  - \"bin/\"\n  - \"localfarm/\"  # For now ignore localfarm as it has no coverage yet\n  - \"meshroom/submitters/\"\n"
  },
  {
    "path": ".git-blame-ignore-revs",
    "content": "# Linting: Harmonize docstrings and comments\n039e0620ad05d673a2ef9aa501e9a509219671c9\n# Linting: Remove all trailing whitespaces\n2c2b067f072856f2579c644b5f7858da0275be3c\n# [core] attribute: Apply linting\nb8c173ddd490dc3bf28b193697d675e44616c6f5\n# Linting: Remove trailing whitespaces\n04a425decc1b80f0c67e8c4c98c0062d73836684\n# [core] Linting: Remove all trailing whitespaces\n8be302115edca60c93b1e97de3f457d91c271666\n# [tests] Linting: Remove trailing whitespaces\n5fe886b6b08fa19082dc0e1bf837fa34c2e2de2d\n# [core] Linting: Remove remaining trailing whitespaces\na44537b65a7c53c89c16e71ae207f37fa6554832\n# Linting: Fix E203, E225, E231, E261, E302, E303 and W292 warnings\n1b5664c8cc54c55fae58a5be9bf63e9af2f5af95\n# [ui] Linting: Remove all trailing whitespaces\n18d7f609b1a5cd7c43f970770374b649019c1e73\n# [core] Linting: Fix import order\naae05532b2409e3bd4c119646afc08656a916cb4\n# [core] Linting: Remove all trailing whitespaces\n81394d7def1fcbc08cbc2a8721bc1f0a86fe8cc6\n# [desc] Linting: Remove trailing whitespaces\n0a2ab8cab4f79191b0e7364416701ba561e75b6a\n# [desc] Linting: Fix order of the imported modules\nadf67e33533a75f5b280e0ee3fc0ece82307199e\n# [build] `setup.py`: Use double quotes everywhere\n571de38ef1a9e72e6c1d2a997b60de5bd3caa5bf\n# [bin] `meshroom_batch`: Minor clean-up in the file\n15d9ecd888faa7216cfc5d97d473f5717a3118a3\n# [core] Linting following CI's flake8 report\n9b4bd68d5aa9e5c3af5e4bfc4fe6aae06437ca88\n# [tests] Linting following CI's flake8 report\n9b6549cc1dd525658080303f7ad453bd4ec10f52\n# [GraphEditor] Indentation fix\n87c0cef605e4ef2b359d7e678155e79b65b2e762\n# [qt6][qml] Clean-up code and harmonize comments\n5a0b1c0c9547b0d00f3f10fae6994d6d8ea0b45e\n# [nodes] Linting: Clean-up files\n4c0409f573c2694325b104c2686a1532f95cb9bc\n# Linting: Clean-up files\n41e885d9ff38cd55772722376d5ef80ff908c559\n# [Viewer] SequencePlayer: Clean-up: Harmonize syntax\n42157809b90f5f6b275aa8ff9d7310c384ea395a\n# [Viewer] Clean-up: Harmonize syntax for the Viewer2D\n9af65092b9e881c828430f54a73fb4522bc1e370\n# [nodes] Harmonize the use of trailing commas across all the nodes\n61a8dcd4e2878f80b2f320f2b1c3c9b41e999b82\n# [nodes] Clean-up: Harmonize nodes' descriptions\nf2d67706511954aa3e1c026ecc858beb8c08f938\n# [qml] Clean-up: Harmonize syntax across all files\ne463f0dce2455f47d5b066f9e9434ed94b2b282f\n# [GraphEditor] Clean-up: Harmonize syntax across all files\ne9d80611c7fe185623e5f276a41b7f2de23cb6fe\n# [ImageGallery] Clean-up: Harmonize syntax across all files\n2bdf061d2e49f3e1513a59922dc33e69f68552cf\n# [Controls] Clean-up: Harmonize syntax across all files\n2908aa94a3eda2de71f8c5e6cec8cd78280bbb09\n# [Charts] Clean-up: Harmonize syntax across all files\n856641bc9dc25271062dc94a66da4c08e00f88d1\n# [Utils] Clean-up: Harmonize syntax across all files\n8313e42d8c70e2494277e338ef8fd38824270231\n# [Viewer] Clean-up: Harmonize syntax across all files\n13b8266d14783a4c595c8b731c54fc9c61adfa92\n# [Viewer3D] Clean-up: Harmonize syntax across all files\n9d2974d2823fe5d6f400eb4658f67d0306b11ac8\n"
  },
  {
    "path": ".github/FUNDING.yml",
    "content": "github: [alicevision]\ncustom: ['https://alicevision.org/association/#donate']\n"
  },
  {
    "path": ".github/ISSUE_TEMPLATE/bug_report.md",
    "content": "---\nname: Bug report\nabout: Create a report to help us improve\ntitle: \"[bug]\"\nlabels: bug\nassignees: ''\n\n---\n\n**Describe the bug**\nA clear and concise description of what the bug is.\n\n**To Reproduce**\nSteps to reproduce the behavior:\n1. Go to '...'\n2. Click on '....'\n3. Scroll down to '....'\n4. See error\n\n**Expected behavior**\nA clear and concise description of what you expected to happen.\n\n**Screenshots**\nIf applicable, add screenshots to help explain your problem.\n\n**Log**\nIf applicable, copy paste the relevant log output (please embed the text in a markdown code tag \"\\`\\`\\`\" )\n\n**Desktop (please complete the following and other pertinent information):**\n - OS: [e.g. win 10, osx, ]\n - Python version [e.g. 2.6]\n - Qt/PySide version [e.g. 6.8.2]\n - Meshroom version: please specify if you are using a release version or your own build\n   - Binary version (if applicable) [e.g. 2023.3.0]\n   - Commit reference (if applicable) [e.g. 08ddbe2]\n\n**Additional context**\nAdd any other context about the problem here.\n"
  },
  {
    "path": ".github/ISSUE_TEMPLATE/feature_request.md",
    "content": "---\nname: Feature request\nabout: Suggest an idea for this project\ntitle: \"[request]\"\nlabels: feature request\nassignees: ''\n\n---\n\n**Is your feature request related to a problem? Please describe.**\nA clear and concise description of what the problem is. Ex. I am always frustrated when [...]\n\n**Describe the solution you'd like**\nA clear and concise description of what you want to happen.\n\n**Describe alternatives you have considered**\nA clear and concise description of any alternative solutions or features you have considered.\n\n**Additional context**\nAdd any other context or screenshots about the feature request here.\n"
  },
  {
    "path": ".github/ISSUE_TEMPLATE/question_help.md",
    "content": "---\nname: Question or help needed\nabout: 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...)\ntitle: \"[question]\"\nlabels: type:question\nassignees: ''\n\n---\n\n**Describe the problem**\nA clear and concise description of what the problem is.\n\n**Screenshots**\nIf applicable, add screenshots to help explain your problem.\n\n**Dataset**\nIf applicable, add a link or *few* images to help better understand where the problem may come from.\n\n**Log**\nIf applicable, copy paste the relevant log output (please embed the text in a markdown code tag \"\\`\\`\\`\" )\n\n**Desktop (please complete the following and other pertinent information):**\n - OS: [e.g. win 10, osx, ]\n - Python version [e.g. 2.6]\n - Qt/PySide version [e.g. 6.8.2]\n - Meshroom version: please specify if you are using a release version or your own build\n   - Binary version (if applicable) [e.g. 2023.3.0]\n   - Commit reference (if applicable) [e.g. 08ddbe2]\n\n**Additional context**\nAdd any other context about the problem here.\n"
  },
  {
    "path": ".github/pull_request_template.md",
    "content": "<!-- Checklist before submission:\n\n - I have read the [contribution guidelines](../CONTRIBUTING.md).\n - I have updated the documentation, if applicable.\n - I have ensured that the change is tested somewhere.\n - I have followed the prevailing code style (for history readability and limit conflicts for maintenance).\n\n-->\n## Description\n\n\n\n## Features list\n\n<!--\n- [ ] Feature one. Fix #XXX\n- [ ] Improve something else\n- [ ] Connect to #3 (to declare link to issues without closing it when the PR is merged).\n- [X] Add \"X\" when it is done.\n-->\n\n\n## Implementation remarks\n\n\n<!--\nExplain main implementation choices.\nIt is also the right place to ask for feedback and help when you hesitate on the implementation.\n-->\n"
  },
  {
    "path": ".github/stale.yml",
    "content": "# Number of days of inactivity before an issue becomes stale\ndaysUntilStale: 120\n# Number of days of inactivity before a stale issue is closed\ndaysUntilClose: 7\n# Issues with these labels will never be considered stale\nexemptLabels:\n  - \"do not close\"\n  - \"feature request\"\n  - \"scope:doc\"\n  - \"new feature\"\n  - \"bug\"\n# Label to use when marking an issue as stale\nstaleLabel: stale\n# Comment to post when marking an issue as stale. Set to `false` to disable\nmarkComment: >\n  This issue has been automatically marked as stale because it has not had\n  recent activity. It will be closed if no further activity occurs. Thank you\n  for your contributions.\n# Comment to post when closing a stale issue. Set to `false` to disable\ncloseComment: >\n  This issue is closed due to inactivity. Feel free to re-open if new information\n  is available.\n"
  },
  {
    "path": ".github/workflows/continuous-integration.yml",
    "content": "name: Continuous Integration\n\non:\n  push:\n    branches:\n      - master\n      - develop\n    # Skip jobs when only documentation files are changed\n    paths-ignore:\n      - '**.md'\n      - '**.rst'\n      - 'docs/**'\n  pull_request:\n    paths-ignore:\n      - '**.md'\n      - '**.rst'\n      - 'docs/**'\n\nenv:\n  CI: True\n  PYTHONPATH: ${{ github.workspace }}\n\njobs:\n  build-linux:\n    runs-on: ubuntu-latest\n    strategy:\n      matrix:\n        python-version: [ 3.11 ]\n\n    steps:\n    - uses: actions/checkout@v3\n    - name: Set up Python ${{ matrix.python-version }}\n      uses: actions/setup-python@v4\n      with:\n        python-version: ${{ matrix.python-version }}\n    - name: Install dependencies\n      run: |\n        python -m pip install --upgrade pip\n        pip install flake8 pytest pytest-cov\n        pip install -r requirements.txt -r dev_requirements.txt --timeout 45\n    - name: Lint with flake8\n      run: |\n        # stop the build if there are Python syntax errors or undefined names\n        flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics\n        # exit-zero treats all errors as warnings. The GitHub editor is 127 chars wide\n        flake8 . --count --exit-zero --max-complexity=10 --max-line-length=127 --statistics\n    - name: Test with pytest\n      run: |\n        pytest tests/\n        pytest --cov --cov-report=xml --junitxml=junit.xml\n    - name: Upload results to Codecov\n      uses: codecov/codecov-action@v5\n      with:\n        token: ${{ secrets.CODECOV_TOKEN }}\n    - name: Upload test results to Codecov\n      if: ${{ !cancelled() }}\n      uses: codecov/test-results-action@v1\n      with:\n        token: ${{ secrets.CODECOV_TOKEN }}\n    - name: Set up Python 3.9 - meshroom_compute test\n      uses: actions/setup-python@v4\n      with:\n        python-version: 3.9\n    - name: Install dependencies (Python 3.9) - meshroom_compute test\n      run: |\n        python3.9 -m pip install --upgrade pip\n        python3.9 -m pip install -r requirements.txt --timeout 45\n    - name: Run imports - meshroom_compute test\n      run: |\n        python3.9 bin/meshroom_compute -h\n\n  build-windows:\n    runs-on: windows-latest\n    strategy:\n      matrix:\n        python-version: [ 3.11 ]\n\n    steps:\n      - uses: actions/checkout@v3\n      - name: Set up Python ${{ matrix.python-version }}\n        uses: actions/setup-python@v4\n        with:\n          python-version: ${{ matrix.python-version }}\n      - name: Install dependencies\n        run: |\n          python -m pip install --upgrade pip\n          pip install flake8 pytest\n          pip install -r requirements.txt -r dev_requirements.txt --timeout 45\n      - name: Lint with flake8\n        run: |\n          # stop the build if there are Python syntax errors or undefined names\n          flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics\n          # exit-zero treats all errors as warnings. The GitHub editor is 127 chars wide\n          flake8 . --count --exit-zero --max-complexity=10 --max-line-length=127 --statistics\n      - name: Test with pytest\n        run: |\n          pytest tests/\n      - name: Set up Python 3.9 - meshroom_compute test\n        uses: actions/setup-python@v4\n        with:\n          python-version: 3.9\n      - name: Install dependencies (Python 3.9) - meshroom_compute test\n        run: |\n          python3 -m pip install --upgrade pip\n          python3 -m pip install -r requirements.txt --timeout 45\n      - name: Run imports - meshroom_compute test\n        run: |\n          python3 bin/meshroom_compute -h\n"
  },
  {
    "path": ".gitignore",
    "content": "# temporary files\n*~\n# vim\n.*.swp\n# emacs\n*.flc\n\\#*\\#\n.\\#*\n# xemacs\n# MacOS\n.DS_Store\n# Windows\nThumbs.db\n# vscode\n.vscode\n\n# python\n*.pyc\n*.pyo\n__pycache__\n\n# backup files\n*.json\n!*Config.json\n\n# datas or personal files\n/data\n/scripts\n/build\n/dist\n/dl\n\n# virtual environment\n/venv\n\n# tests\n/.tests\n/.pytest_cache\n\n# IDEs folders\n*.qmlproject*\n/nbproject\n.idea\n.cache\n.nfs*\n\n*.qmlc\n*.jsc\n\n# QtCreator project files\n*.cflags\n*.cxxflags\n*.creator*\n*.files\n*.includes\n\n*.dll\n\n*.lib\n\ninstall/qml/AliceVision/qmldir\n\nrun.bat\n"
  },
  {
    "path": ".readthedocs.yaml",
    "content": "# .readthedocs.yaml\n# Read the Docs configuration file\n# See https://docs.readthedocs.io/en/stable/config-file/v2.html for details\n\n# Required\nversion: 2\n\n# Build HTML documentation with Sphinx\nsphinx:\n  builder: html\n  configuration: docs/source/conf.py\n\n# Python requirements\npython:\n  install:\n    - requirements: requirements.txt\n    - requirements: dev_requirements.txt\n    - requirements: docs/requirements.txt\n"
  },
  {
    "path": "CHANGES.md",
    "content": "# Meshroom Changelog\n\nFor algorithmic changes related to the photogrammetric pipeline, \nplease refer to [AliceVision changelog](https://github.com/alicevision/AliceVision/blob/develop/CHANGES.md).\n\n## Meshroom 2025.1.0 (2025/08/18)\n\nMeshroom has now become a node-based visual programming toolbox for creating, managing, and executing complex data processing pipelines, with a new plugin architecture.\nStandard 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.\nAdditionally, 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.\n\n### Highlights\n\n#### Meshroom New Features\n\n- **Advanced Plugin Architecture**: Dedicated sub-process isolation for Python nodes with independent local environments\n- **Integrated Development Tools**:\n  - Built-in Python script editor\n  - Node’s source code hot-reload for rapid node development iterations\n- **Enhanced GraphEditor**:\n  - Dynamic output attributes enabling new workflow usages\n  - New InputNode type enabling interactive evaluation without explicit computation\n  - Multiple edge disconnection methods and node colorization for better user experience\n  - Node notifications to attribute changes\n- **Enhanced 2D Viewer**:\n  - Initial timeline integration with sequence playback controls\n  - New Reflectance Transformation Imaging (RTI) Viewer: Interactive visualization of albedo and normal maps with real-time lighting control\n  - New Home Page: featuring pipeline templates and quick access to recent projects.\n\n#### AliceVision Plugin New Features\n\n- New Pipelines\n  - **Color Calibration**: Automated color correction from color charts\n  - **Raw to EXR conversion**: Professional image format processing\n  - **Object Reconstruction**: Targeted reconstruction with automatic object segmentation\n  - **Turntable Object Reconstruction**: Streamlined workflow for rotating object capture\n  - **360° Object Reconstruction**: Reconstruction of complete dual-sided scanning\n  - **LiDAR Processing**: Native E57 file import with integrated mesh generation\n  - **Multi-View Photometric Stereo**: Advanced surface detail reconstruction with multiple light sources for each viewpoint.\n- Pipelines Improvements\n  - **Camera Tracking pipeline**: improved stability and reliability\n  - **Introduced experimental fine-grained pipelines** for increased modularity and workflow flexibility\n- Core Enhancements\n  - **Python Bindings Integration**: Enhanced AliceVision accessibility with native Python support for streamlined Machine Learning workflows\n\n#### New MrSegmentation Plugin\n\nAI segmentation nodes that identify and isolate image objects using natural language prompts, enabling intuitive content-aware processing through foundation models.\n\n#### MeshroomHub Plugins\n\nWe're excited to introduce new experimental Machine Learning plugins available on [MeshroomHub](https://github.com/meshroomHub).\nThese plugins showcase the future of Meshroom workflows, though they currently require developer setup and cannot be installed through the user interface yet.\n- mrGSplat: Gaussian Splat optimization and rendering\n- mrDepthEstimation: Monocular depth inference\n- mrDenseMotion: Optical flow estimation\n- mrRoma: Dense deep feature matching\n- mrIntrinsicImageDecomposition: Albedo, normals, and material extraction\n- mrDeblurring: Video deblurring\n- mrGeolocation: GPS extraction and geographic models download\n\nBased on [AliceVision 3.3.0](https://github.com/alicevision/AliceVision/tree/v3.3.0).\n\n### Major Features\n\n- Add an \"E57\" importer node [PR](https://github.com/alicevision/Meshroom/pull/2308)\n- First node for Lidar Meshing [PR](https://github.com/alicevision/Meshroom/pull/2324)\n- 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)\n- [core] New dynamic output attributes [PR](https://github.com/alicevision/Meshroom/pull/2432)\n- First Homepage [PR](https://github.com/alicevision/Meshroom/pull/2452)\n- Qt6.6.3 / PySide6.6.3.1 upgrade [PR](https://github.com/alicevision/Meshroom/pull/2599)\n- New MultiView Photometric Stereo pipeline and new sfmFilter node [PR](https://github.com/alicevision/Meshroom/pull/2582)\n- [ui] Python Script Editor Improvements [PR](https://github.com/alicevision/Meshroom/pull/2587)\n- New local isolated computation for python nodes [PR](https://github.com/alicevision/Meshroom/pull/2703)\n- New Plugin Architecture for Node Registration [PR](https://github.com/alicevision/Meshroom/pull/2733)\n- [ui]: Introduction of multiple ways to remove Node Edges [PR](https://github.com/alicevision/Meshroom/pull/2644)\n- [core] Runtime-specific environments support [PR](https://github.com/alicevision/Meshroom/pull/2747)\n- [Photometric Stereo] MultiView fusion in Texturing [PR](https://github.com/alicevision/Meshroom/pull/2243)\n- Add a Python ScriptEditor in the GraphEditor tab [PR](https://github.com/alicevision/Meshroom/pull/2456)\n\n### Features\n\n- Custom loader for .pc.ply point clouds [PR](https://github.com/alicevision/Meshroom/pull/2346)\n- Lidar nodes [PR](https://github.com/alicevision/Meshroom/pull/2365)\n- [ui] Viewer2D: Display lighting circle with auto detected sphere [PR](https://github.com/alicevision/Meshroom/pull/2413)\n- [ui] RGBA shortcuts for Image Viewer [PR](https://github.com/alicevision/Meshroom/pull/2425)\n- [ui] Shortcuts in Viewer2D and SequencePlayer [PR](https://github.com/alicevision/Meshroom/pull/2430)\n- [ui] node time computation and chunks count in node editor header [PR](https://github.com/alicevision/Meshroom/pull/1867)\n- [core/ui] Load image sequence from node's output in SequencePlayer [PR](https://github.com/alicevision/Meshroom/pull/2375)\n- [core] Forward the onAttributeChanged notification to all linked attributes [PR](https://github.com/alicevision/Meshroom/pull/2453)\n- add 3de undistortion models [PR](https://github.com/alicevision/Meshroom/pull/2446)\n- [GraphEditor] Base `ChoiceParam` model on attribute instead of description [PR](https://github.com/alicevision/Meshroom/pull/2494)\n- [core] Reference the attribute's instance type in its description [PR](https://github.com/alicevision/Meshroom/pull/2493)\n- [ui] Improve command line help message [PR](https://github.com/alicevision/Meshroom/pull/2518)\n- Added Pre and Post process functions on the Base Node [PR](https://github.com/alicevision/Meshroom/pull/2539)\n- [ui] Add and improve multiple UI tools for Photometric stereo [PR](https://github.com/alicevision/Meshroom/pull/2444)\n- Refactor Node selection for better UX and performance [PR](https://github.com/alicevision/Meshroom/pull/2605)\n- New SfMColorizing Node [PR](https://github.com/alicevision/Meshroom/pull/2610)\n- Update sfm pipeline to accept meshes [PR](https://github.com/alicevision/Meshroom/pull/2642)\n- Enable Fitting of selected Nodes in the Graph Editor when Fit is invoked  [PR](https://github.com/alicevision/Meshroom/pull/2652)\n- Add relative paths to nodes as variables [PR](https://github.com/alicevision/Meshroom/pull/2629)\n- Node to inject survey points in the SFM [PR](https://github.com/alicevision/Meshroom/pull/2696)\n- [ui] AttributeEditor: Feature/attribute navigation buttons [PR](https://github.com/alicevision/Meshroom/pull/2716)\n- [ui] Homepage: Project can be removed with right click [PR](https://github.com/alicevision/Meshroom/pull/2724)\n- [ui] Viewer2D: Add the pixel (x,y) values in the toolbar (editable) [PR](https://github.com/alicevision/Meshroom/pull/2723)\n- [ui] AttributeEditor: Allow displaying attibute in corresponding viewport  [PR](https://github.com/alicevision/Meshroom/pull/2722)\n- Update to Qt/PySide 6.8.3 [PR](https://github.com/alicevision/Meshroom/pull/2692)\n- Add a \"ConvertDistortion\" node [PR](https://github.com/alicevision/Meshroom/pull/2353)\n- [ui] Sync SequencePlayer and Viewer3D [PR](https://github.com/alicevision/Meshroom/pull/2360)\n- Viewer3D: Adjust bounding-box by moving faces [PR](https://github.com/alicevision/Meshroom/pull/2385)\n- [core/ui] Add support for PushButton attribute [PR](https://github.com/alicevision/Meshroom/pull/2382)\n- First version of For Loop implementation [PR](https://github.com/alicevision/Meshroom/pull/2504)\n- Generate depthmaps from sfmData and mesh [PR](https://github.com/alicevision/Meshroom/pull/2556)\n- [ui] Use the improved Sequence Player and enable it by default [PR](https://github.com/alicevision/Meshroom/pull/2557)\n- [AttributePin] Add tooltip to display type of attribute [PR](https://github.com/alicevision/Meshroom/pull/2527)\n- [core/ui] \"Exposed\" property added to attributeDesc [PR](https://github.com/alicevision/Meshroom/pull/2528)\n- Extract more metadata using exifTool [PR](https://github.com/alicevision/Meshroom/pull/2645)\n- Add equirectangular camera model in `CameraInit` [PR](https://github.com/alicevision/Meshroom/pull/2630)\n- Fix: Improve large project file loading performance [PR](https://github.com/alicevision/Meshroom/pull/2665)\n- UI: Redesign ChoiceParam UI component [PR](https://github.com/alicevision/Meshroom/pull/2656)\n- Create new pipeline for testing modular sfm [PR](https://github.com/alicevision/Meshroom/pull/2664)\n- [ui] Graph Editor Update: Quick Node Coloring with the Color Selector Tool [PR](https://github.com/alicevision/Meshroom/pull/2604)\n- [doc] README.md: Add DeepWiki link, the AI documentation you can talk to [PR](https://github.com/alicevision/Meshroom/pull/2792)\n\n### Other Improvements\n\n- Start Development 2024.1.0 [PR](https://github.com/alicevision/Meshroom/pull/2268)\n- ImageSegmentation: add an option to choose between cpu and gpu [PR](https://github.com/alicevision/Meshroom/pull/2267)\n- [Viewer] Display error labels when an image cannot be loaded [PR](https://github.com/alicevision/Meshroom/pull/2250)\n- [MaterialIcons] Add script to generate the list of available MaterialIcons and update it [PR](https://github.com/alicevision/Meshroom/pull/2247)\n- Add option to keep input filename in imageSegmentation [PR](https://github.com/alicevision/Meshroom/pull/2288)\n- Add camera color spaces [PR](https://github.com/alicevision/Meshroom/pull/2251)\n- [docker] Fix link to download `libassimpsceneimport.so` in Docker images [PR](https://github.com/alicevision/Meshroom/pull/2310)\n- Added PLY to list of supported files in 3D viewer [PR](https://github.com/alicevision/Meshroom/pull/2316)\n- E57 importer is now generating multiple sfmData [PR](https://github.com/alicevision/Meshroom/pull/2318)\n- Added semantic logic to display multiple 3d objects [PR](https://github.com/alicevision/Meshroom/pull/2320)\n- [submitters] Update SimpleFarm configuration tags [PR](https://github.com/alicevision/Meshroom/pull/2348)\n- [ui] drag&drop: common behavior for graph editor and image gallery [PR](https://github.com/alicevision/Meshroom/pull/2342)\n- [core] Add new type of ChoiceParam that changes dynamically [PR](https://github.com/alicevision/Meshroom/pull/2350)\n- [ui] Add new FilterComboBox for ChoiceParam attributes [PR](https://github.com/alicevision/Meshroom/pull/2358)\n- [core/ui] Hide output attributes flagged for visualisation [PR](https://github.com/alicevision/Meshroom/pull/2369)\n- Update ripple constraints [PR](https://github.com/alicevision/Meshroom/pull/2374)\n- Hide disabled File attributes and their connections [PR](https://github.com/alicevision/Meshroom/pull/1925)\n- [ui] Sequence Player UX improvements (fps, slider, frame) [PR](https://github.com/alicevision/Meshroom/pull/2362)\n- [core] BugFix : Upgrade of Dynamic Choice Param fixed [PR](https://github.com/alicevision/Meshroom/pull/2380)\n- [ui] Bounding Box are usable in other nodes, not only Meshing [PR](https://github.com/alicevision/Meshroom/pull/2391)\n- [ui] Cut option available in GraphEditor [PR](https://github.com/alicevision/Meshroom/pull/2399)\n- [core] Set internal attributes when copy/pasting nodes [PR](https://github.com/alicevision/Meshroom/pull/2390)\n- [ImageGallery] Display CameraInit label and defaultLabel to avoid confusion [PR](https://github.com/alicevision/Meshroom/pull/2383)\n- [GraphEditor] Internal Custom Color Picker disabled when node is locked [PR](https://github.com/alicevision/Meshroom/pull/2384)\n- Bump requests from 2.27.1 to 2.32.0 [PR](https://github.com/alicevision/Meshroom/pull/2405)\n- [ui] Selected node header set to base color [PR](https://github.com/alicevision/Meshroom/pull/2401)\n- [ui] Remove intrinsic if not used by any viewpoint [PR](https://github.com/alicevision/Meshroom/pull/2395)\n- [ui] Right click on text element in AttributeEditor open Copy/Paste menu [PR](https://github.com/alicevision/Meshroom/pull/2366)\n- [ui] Fix BoundingBox visibility icon because of mapping name [PR](https://github.com/alicevision/Meshroom/pull/2386)\n- Add track coordinates [PR](https://github.com/alicevision/Meshroom/pull/2406)\n- [ui] Conversion of relative paths to absolute ones [PR](https://github.com/alicevision/Meshroom/pull/2412)\n- [core] Compare last saved date before saving to prevent overwrite [PR](https://github.com/alicevision/Meshroom/pull/2414)\n- Fix 3D Viewer zooming problem [PR](https://github.com/alicevision/Meshroom/pull/2379)\n- [ui] Use ExportAnimatedCamera output for image overlay in Viewer3D [PR](https://github.com/alicevision/Meshroom/pull/2398)\n- [GraphEditor] Eye on displayable node even if not computed [PR](https://github.com/alicevision/Meshroom/pull/2427)\n- [ui] Add \"large\" option to multiline string param [PR](https://github.com/alicevision/Meshroom/pull/2437)\n- [ui] Auto Update CameraInit when displaying node [PR](https://github.com/alicevision/Meshroom/pull/2431)\n- Fix compatibility upgrade issue [PR](https://github.com/alicevision/Meshroom/pull/2436)\n- Depth map filter: display normals if enabled [PR](https://github.com/alicevision/Meshroom/pull/2442)\n- [ui] do not use native dialog [PR](https://github.com/alicevision/Meshroom/pull/2439)\n- File export ordering [PR](https://github.com/alicevision/Meshroom/pull/2440)\n- [SequencePlayer] Fetching option added [PR](https://github.com/alicevision/Meshroom/pull/2415)\n- Provide access to the current frame from the graph [PR](https://github.com/alicevision/Meshroom/pull/2443)\n- Update ripple with \"cuda\" instead of \"gpu\" [PR](https://github.com/alicevision/Meshroom/pull/2448)\n- Provide access to the path of the currently displayed frame [PR](https://github.com/alicevision/Meshroom/pull/2449)\n- [Viewer] Fix all QML errors on the Sequence Player [PR](https://github.com/alicevision/Meshroom/pull/2451)\n- Remove plugin loading from core __init__ [PR](https://github.com/alicevision/Meshroom/pull/2458)\n- [ui] Sequence Player UI Modifications [PR](https://github.com/alicevision/Meshroom/pull/2445)\n- [ui] Add MESHROOM_USE_SEQUENCE_PLAYER environment variable [PR](https://github.com/alicevision/Meshroom/pull/2463)\n- Display ION container version in Meshroom  [PR](https://github.com/alicevision/Meshroom/pull/2468)\n- Compute or Submit selected nodes [PR](https://github.com/alicevision/Meshroom/pull/2459)\n- Add new SfMExpanding node [PR](https://github.com/alicevision/Meshroom/pull/2416)\n- Add squeeze option [PR](https://github.com/alicevision/Meshroom/pull/2466)\n- [Viewer] Current frame for Sequence should not be set during changes of Image Gallery [PR](https://github.com/alicevision/Meshroom/pull/2472)\n- Remove some computers even for normal tasks [PR](https://github.com/alicevision/Meshroom/pull/2479)\n- [GraphEditor] Implementation of Recompute Button [PR](https://github.com/alicevision/Meshroom/pull/2473)\n- [core] Attribute: Directly access description's type in `getType()` [PR](https://github.com/alicevision/Meshroom/pull/2490)\n- [Viewer] Update error values for QtAV's `EStatus` enum [PR](https://github.com/alicevision/Meshroom/pull/2491)\n- [GraphEditor] Improve visibility of chunks in progress bar [PR](https://github.com/alicevision/Meshroom/pull/2507)\n- [ui] Correctly lose focus on `StringParam` when clicking outside of its text field [PR](https://github.com/alicevision/Meshroom/pull/2512)\n- Multiple shots: Align and merge multiple SfM from feature matches [PR](https://github.com/alicevision/Meshroom/pull/2484)\n- Homepage Quick Adjustments [PR](https://github.com/alicevision/Meshroom/pull/2520)\n- Add locks for intrinsics [PR](https://github.com/alicevision/Meshroom/pull/2517)\n- sfmTransform: Add option to lineup camera motion with object/lidar given an external camera pose [PR](https://github.com/alicevision/Meshroom/pull/2524)\n- [ui] Open project from browser in homepage & quick adjustments [PR](https://github.com/alicevision/Meshroom/pull/2525)\n- [ui] Minor UI modifications [PR](https://github.com/alicevision/Meshroom/pull/2530)\n- [ui] Fix click on Category in Node Menu to keep the nodes displayed [PR](https://github.com/alicevision/Meshroom/pull/2526)\n- [core] Simplify attribute invalidation in nodes' descriptions [PR](https://github.com/alicevision/Meshroom/pull/2523)\n- UI Changes [PR](https://github.com/alicevision/Meshroom/pull/2531)\n- [AttributeItemDelegate] Position the attribute description tooltip [PR](https://github.com/alicevision/Meshroom/pull/2532)\n- [ui] Add View Image Gallery Parameter [PR](https://github.com/alicevision/Meshroom/pull/2541)\n- [core] Simplify node descriptions [PR](https://github.com/alicevision/Meshroom/pull/2538)\n- Use export distortion and new segmentation node in templates [PR](https://github.com/alicevision/Meshroom/pull/2549)\n- Add wireframe for Qt6 [PR](https://github.com/alicevision/Meshroom/pull/2561)\n- Change picking behavior for qt6 upgrade [PR](https://github.com/alicevision/Meshroom/pull/2564)\n- [qt6] Fix 8Bits image viewer zoom/fit [PR](https://github.com/alicevision/Meshroom/pull/2565)\n- [blender] Adapt `ScenePreview`'s Blender script to pixel ratio [PR](https://github.com/alicevision/Meshroom/pull/2572)\n- Update panorama display [PR](https://github.com/alicevision/Meshroom/pull/2573)\n- Fix attribute value change propagation and callback handling [PR](https://github.com/alicevision/Meshroom/pull/2586)\n- Tracking pipelines segmentation update [PR](https://github.com/alicevision/Meshroom/pull/2583)\n- [qt6]|Viewer3D] Fix mouse for camera controller [PR](https://github.com/alicevision/Meshroom/pull/2566)\n- Discard attribute changed callbacks during graph loading [PR](https://github.com/alicevision/Meshroom/pull/2598)\n- Split `meshroom.core.desc` module into a package with submodules [PR](https://github.com/alicevision/Meshroom/pull/2592)\n- [ui] Minor UI stabilization fixes for Qt 6 [PR](https://github.com/alicevision/Meshroom/pull/2606)\n- [ui] Fix field of view functions for tall images [PR](https://github.com/alicevision/Meshroom/pull/2609)\n- [Viewer3D] Apply the pixel aspect ratio for the Frame Overlay [PR](https://github.com/alicevision/Meshroom/pull/2533)\n- [ui] Improve Search Bar component [PR](https://github.com/alicevision/Meshroom/pull/2581)\n- [BugFix] File save dialog now requires a valid filename [PR](https://github.com/alicevision/Meshroom/pull/2602)\n- [GraphEditor] AttributeItemDelegate: Use MaterialLabel for uncomputed attributes [PR](https://github.com/alicevision/Meshroom/pull/2616)\n- CI: add codecov [PR](https://github.com/alicevision/Meshroom/pull/2618)\n- Sfm Bootstraping parameterization [PR](https://github.com/alicevision/Meshroom/pull/2619)\n- Fix Qt6-induced issues [PR](https://github.com/alicevision/Meshroom/pull/2620)\n- [ui] GraphEditor: Address Key Event Conflicts in Node Menu [PR](https://github.com/alicevision/Meshroom/pull/2622)\n- [ui] Add Validation for Save file path accessibility [PR](https://github.com/alicevision/Meshroom/pull/2625)\n- [ui] NodeEditor: Addressed Tab Retention when switching Node selection [PR](https://github.com/alicevision/Meshroom/pull/2624)\n- Add support for QML debugging/profiling [PR](https://github.com/alicevision/Meshroom/pull/2623)\n- [GraphEditor] Fix injections into signal handlers with JS functions [PR](https://github.com/alicevision/Meshroom/pull/2627)\n- [ui] \"About\" dialog: Fix some display issues [PR](https://github.com/alicevision/Meshroom/pull/2640)\n- Update version number and copyrights [PR](https://github.com/alicevision/Meshroom/pull/2639)\n- 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)\n- [ui] Moved Auto-Layout Depth Settings under Graph Editor Menu [PR](https://github.com/alicevision/Meshroom/pull/2646)\n- Enable merge of multiple sfmDatas [PR](https://github.com/alicevision/Meshroom/pull/2654)\n- [ui][fix] Edge: Fixing an issue with mouse event on Custom EdgeMouseArea causing Crash [PR](https://github.com/alicevision/Meshroom/pull/2650)\n- [ui] Refactor the access to the list of recent project files [PR](https://github.com/alicevision/Meshroom/pull/2637)\n- Mask processing node [PR](https://github.com/alicevision/Meshroom/pull/2658)\n- Export Maya .mel Script  [PR](https://github.com/alicevision/Meshroom/pull/2617)\n- Refactor Graph de/serialization [PR](https://github.com/alicevision/Meshroom/pull/2612)\n- Node: Propagate attribute change via `valueChanged` signal [PR](https://github.com/alicevision/Meshroom/pull/2657)\n- [qml] Fix QML warnings related to chunks [PR](https://github.com/alicevision/Meshroom/pull/2673)\n- Add maya scene export [PR](https://github.com/alicevision/Meshroom/pull/2674)\n- NodeAPI: Trigger node creation callback only for explicit new node creation [PR](https://github.com/alicevision/Meshroom/pull/2671)\n- [ui] app: Register components to QML before instantiating the engine [PR](https://github.com/alicevision/Meshroom/pull/2676)\n- [ui] Application: fix save-as dialog not working properly (Qt6.7+) [PR](https://github.com/alicevision/Meshroom/pull/2683)\n- [GraphEditor] Only display \"Pipelines\" menu when templates are available [PR](https://github.com/alicevision/Meshroom/pull/2678)\n- [qml] Fix QML warnings when dropping project files into the Graph Editor [PR](https://github.com/alicevision/Meshroom/pull/2680)\n- Export USD Node [PR](https://github.com/alicevision/Meshroom/pull/2667)\n- [ui] AttributeEditor: Generic TextField param editor improvements [PR](https://github.com/alicevision/Meshroom/pull/2686)\n- ChoiceParam: add option to serialize overriden values [PR](https://github.com/alicevision/Meshroom/pull/2682)\n- [core] Node: Status should be `NONE` when there is no chunk [PR](https://github.com/alicevision/Meshroom/pull/2695)\n- Move nodes and templates to AliceVision's repository [PR](https://github.com/alicevision/Meshroom/pull/2697)\n- Remove internal and no longer used files [PR](https://github.com/alicevision/Meshroom/pull/2711)\n- Modernize to python 3.9 using flynt and pyupgrade [PR](https://github.com/alicevision/Meshroom/pull/2710)\n- [doc] README: Clarified distinction between Meshroom engine, user interface, and plugins [PR](https://github.com/alicevision/Meshroom/pull/2718)\n- Use shutil to load nvidia-smi [PR](https://github.com/alicevision/Meshroom/pull/2721)\n- [ui] Viewer2D can display the content of tracks files [PR](https://github.com/alicevision/Meshroom/pull/2720)\n- [ui] [fix] Attribute: Fix the qml warnings on intrisincs [PR](https://github.com/alicevision/Meshroom/pull/2739)\n- [ui] Application: Use CamelCase and disable tooltips when menus are disabled [PR](https://github.com/alicevision/Meshroom/pull/2742)\n- ListAttribute: fix methods not considering connected attribute's value [PR](https://github.com/alicevision/Meshroom/pull/2660)\n- [fix] remove targetSize in viewer2d which was removed in qtAliceVision [PR](https://github.com/alicevision/Meshroom/pull/2746)\n- [ui] Homepage: Update logos of sponsors [PR](https://github.com/alicevision/Meshroom/pull/2729)\n- [ui] Rework of MessageDialog for CompatibilityManager and SensorDBDialog [PR](https://github.com/alicevision/Meshroom/pull/2537)\n- [qml] Fix some minor QML warnings [PR](https://github.com/alicevision/Meshroom/pull/2756)\n- Add support for `ALICEVISION_LIBPATH` environment variable [PR](https://github.com/alicevision/Meshroom/pull/2757)\n- [docker] minor updates [PR](https://github.com/alicevision/Meshroom/pull/2765)\n- [core] plugins: Add support for virtual environments on Windows [PR](https://github.com/alicevision/Meshroom/pull/2768)\n- [core] Adding rangeBlocksCount to `Parallelization` [PR](https://github.com/alicevision/Meshroom/pull/2767)\n- Bump requests from 2.32.0 to 2.32.4 [PR](https://github.com/alicevision/Meshroom/pull/2743)\n- Fix colorHueComponent slider background [PR](https://github.com/alicevision/Meshroom/pull/2788)\n- [core] plugins: Look recursively for \"lib\" directories in Linux venv [PR](https://github.com/alicevision/Meshroom/pull/2777)\n- [core] plugins: Virtual environments should be named \"venv\" instead of having the plugin's name [PR](https://github.com/alicevision/Meshroom/pull/2793)\n- [qml] Minor UI fixes [PR](https://github.com/alicevision/Meshroom/pull/2783)\n- [qml] Use native FileDialogs [PR](https://github.com/alicevision/Meshroom/pull/2784)\n- Set the default environment variables for the color chart detection models [PR](https://github.com/alicevision/Meshroom/pull/2796)\n- [ui] Remove the `Live Reconstruction` and `Augment Reconstruction` features [PR](https://github.com/alicevision/Meshroom/pull/2786)\n- Improve behaviour when dropping folders [PR](https://github.com/alicevision/Meshroom/pull/2797)\n- [core] plugins: Load plugin's configuration file upon its initialisation [PR](https://github.com/alicevision/Meshroom/pull/2778)\n- [core] plugins: Downgrade the log level when loading the config file [PR](https://github.com/alicevision/Meshroom/pull/2798)\n\n### Bugfixes\n\n- Fix duplicated icon in MaterialIcons [PR](https://github.com/alicevision/Meshroom/pull/2277)\n- Correctly delete thread pools when exiting Meshroom with Python 3.9 [PR](https://github.com/alicevision/Meshroom/pull/2286)\n- [Viewer] Viewer: Fix various issues with the 2D Viewer [PR](https://github.com/alicevision/Meshroom/pull/2283)\n- Use the correct response file to display the graph of the Camera Response Function [PR](https://github.com/alicevision/Meshroom/pull/2282)\n- Update `ListAttributes` identically when removing edges or nodes [PR](https://github.com/alicevision/Meshroom/pull/2280)\n- Upgrade intrinsics for distortion [PR](https://github.com/alicevision/Meshroom/pull/2349)\n- [ui] Correctly display images from node outputs even if there is no `CameraInit` node [PR](https://github.com/alicevision/Meshroom/pull/2363)\n- [ui] Scroll available in FilterComboBox [PR](https://github.com/alicevision/Meshroom/pull/2376)\n- [Viewer] fix lens distortion viewer status when switching between projects [PR](https://github.com/alicevision/Meshroom/pull/2377)\n- [ui] Fix drag and drop of heavy number of frames [PR](https://github.com/alicevision/Meshroom/pull/2378)\n- SequencePlayer: Forbid \"selecting\" an invalid frame number [PR](https://github.com/alicevision/Meshroom/pull/2388)\n- [ui] Prevent Feature Points to display on external images [PR](https://github.com/alicevision/Meshroom/pull/2389)\n- [ui/core] Fix get latest SfM node for previz [PR](https://github.com/alicevision/Meshroom/pull/2396)\n- [nodes/ui] Fix ExportAnimatedCamera outputs for ScenePreview use [PR](https://github.com/alicevision/Meshroom/pull/2420)\n- [fix] Various fixes [PR](https://github.com/alicevision/Meshroom/pull/2419)\n- Prevent updates of the latest SfM node when the graph's topology is dirty [PR](https://github.com/alicevision/Meshroom/pull/2435)\n- [Utils] `getTimeStr`: Round up the number of minutes correctly [PR](https://github.com/alicevision/Meshroom/pull/2254)\n- [ui] Graph: Connect all chunks when setting a graph for the first time [PR](https://github.com/alicevision/Meshroom/pull/2454)\n- [core] Exclude edges from `InputNode` nodes in `dfsToProcess` [PR](https://github.com/alicevision/Meshroom/pull/2455)\n- [core] Values of ChoiceParam should be a list, Error message added for initialisation [PR](https://github.com/alicevision/Meshroom/pull/2469)\n- Some fixes for dynamic output attributes [PR](https://github.com/alicevision/Meshroom/pull/2470)\n- [ui] Fix local computation of subgraphs for unsaved projects [PR](https://github.com/alicevision/Meshroom/pull/2471)\n- [ui] Fix Camera Init Group Index should stay the same at adding or removing CameraInit events [PR](https://github.com/alicevision/Meshroom/pull/2474)\n- [Viewer2D] Only reset index of currentFrame if the currentFrame is after max of frameRange [PR](https://github.com/alicevision/Meshroom/pull/2480)\n- [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)\n- [GraphEditor] AttributeItemDelegate: Return valid component for `PushButton` [PR](https://github.com/alicevision/Meshroom/pull/2482)\n- Initialize `core` plugins at different moments [PR](https://github.com/alicevision/Meshroom/pull/2487)\n- [ui] app: Correctly reload list of available templates [PR](https://github.com/alicevision/Meshroom/pull/2499)\n- [core] Catch exception for calls to optional descriptor method on node creation [PR](https://github.com/alicevision/Meshroom/pull/2500)\n- [ui] Improve sequence display [PR](https://github.com/alicevision/Meshroom/pull/2502)\n- [ui] GraphEditor.newNodeMenu: fix unstable menu height [PR](https://github.com/alicevision/Meshroom/pull/2511)\n- [ui] Add proper distinction between the main window and the application [PR](https://github.com/alicevision/Meshroom/pull/2521)\n- [ui] Fix function evaluations in invalid QML context and minor fixes [PR](https://github.com/alicevision/Meshroom/pull/2519)\n- Fix Several Compatibility Nodes Operations [PR](https://github.com/alicevision/Meshroom/pull/2506)\n- [main] Fix imagesFolder variable in order to save when gallery is not empty [PR](https://github.com/alicevision/Meshroom/pull/2535)\n- [bin] Import correct `Graph` objects for `meshroom_batch` [PR](https://github.com/alicevision/Meshroom/pull/2536)\n- Fix homepage SplitViews [PR](https://github.com/alicevision/Meshroom/pull/2545)\n- [core] Check provided template folder exists before attempting to load it [PR](https://github.com/alicevision/Meshroom/pull/2552)\n- [img] Remove incorrect sRGB profile from UiO logo [PR](https://github.com/alicevision/Meshroom/pull/2555)\n- [ui] multiple fixes related to split view and node status checks [PR](https://github.com/alicevision/Meshroom/pull/2568)\n- [ui] Various minor UI fixes [PR](https://github.com/alicevision/Meshroom/pull/2563)\n- [core] Node: Do not automatically upgrade unknown nodes in templates [PR](https://github.com/alicevision/Meshroom/pull/2558)\n- [GraphEditor] Node: Check if unexposed `ListAttributes` contain links [PR](https://github.com/alicevision/Meshroom/pull/2578)\n- [GraphEditor] Edge: Correctly update the `EdgeMouseArea` when moving nodes [PR](https://github.com/alicevision/Meshroom/pull/2613)\n- Fix projects disappearing from the list of recent projects [PR](https://github.com/alicevision/Meshroom/pull/2615)\n- [ImageGallery] Intrinsics table: Always fully instantiate the model before populating it [PR](https://github.com/alicevision/Meshroom/pull/2655)\n- [ui] Graph: In minimal refresh, do not poll files for chunks run locally [PR](https://github.com/alicevision/Meshroom/pull/2672)\n- Fix Meshroom App CLI `latest` option [PR](https://github.com/alicevision/Meshroom/pull/2675)\n- [bin] `meshroom_batch`: Stop using removed `defaultCacheFolder` [PR](https://github.com/alicevision/Meshroom/pull/2715)\n- [desc] Import `CREATE_NEW_PROCESS_GROUP` flag from `subprocess` [PR](https://github.com/alicevision/Meshroom/pull/2719)\n- [ui] Reconstruction: Restore the `Slot` status of the `clear` method [PR](https://github.com/alicevision/Meshroom/pull/2732)\n- [core] attribute: Fix `hasOutputConnections` for ListAttributes [PR](https://github.com/alicevision/Meshroom/pull/2731)\n- Fix elapsed time when there is only one chunk [PR](https://github.com/alicevision/Meshroom/pull/2734)\n- bugfix ExecMode status [PR](https://github.com/alicevision/Meshroom/pull/2737)\n- [ui] Update node status when modified [PR](https://github.com/alicevision/Meshroom/pull/2738)\n- [ui] [fix] MediaLibrary: Check if the model.source is actually an Attribute… [PR](https://github.com/alicevision/Meshroom/pull/2736)\n- [ui] [fix] Viewer2D: Failure on MousePosition on some edge cases [PR](https://github.com/alicevision/Meshroom/pull/2741)\n- [core] Templates test: Remove outdated `unregisterNodeType` import [PR](https://github.com/alicevision/Meshroom/pull/2750)\n- [ui] GraphEditor fix: Remove useless link between height and implicitHeight [PR](https://github.com/alicevision/Meshroom/pull/2749)\n- [core] Templates test: Access node descriptor from `NodePlugin` object [PR](https://github.com/alicevision/Meshroom/pull/2751)\n- [core] Stop checking for templates in \"pipelines\" folder [PR](https://github.com/alicevision/Meshroom/pull/2752)\n- [ui] [fix] Viewer2D: using the keyboard shortcuts (r,g,b,a) break the channelBox combobox [PR](https://github.com/alicevision/Meshroom/pull/2753)\n- [ui] Reconstruction: Fix setup of temporary `CameraInit` nodes [PR](https://github.com/alicevision/Meshroom/pull/2762)\n- [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)\n- [core] desc.node: Ensure all paths are sent to the command line as POSIX strings [PR](https://github.com/alicevision/Meshroom/pull/2760)\n- [ui] Nodes: Update the deprecated import of QGraphicEffects. [PR](https://github.com/alicevision/Meshroom/pull/2755)\n- [ui] Import images: Fix that trying to import images twic, the dialog… [PR](https://github.com/alicevision/Meshroom/pull/2763)\n- Meshing: boundingBox working with qt6 [PR](https://github.com/alicevision/Meshroom/pull/2766)\n- Fix manual frame selection in viewer 2D [PR](https://github.com/alicevision/Meshroom/pull/2769)\n- [ui] app: Correctly evaluate env vars that enable/disable components [PR](https://github.com/alicevision/Meshroom/pull/2772)\n- Fix for QFontDatabase crash on exit [PR](https://github.com/alicevision/Meshroom/pull/2776)\n- [ui] Add project to recent projects when dropping a file [PR](https://github.com/alicevision/Meshroom/pull/2483)\n- [ui] fix: Overlay image does not work on pipeline \"Photogrametry experimental\"  [PR](https://github.com/alicevision/Meshroom/pull/2780)\n- [core] Parallelization: the cmdline suffix should be at the end [PR](https://github.com/alicevision/Meshroom/pull/2794)\n\n### CI, Documentation and Build\n\n- Add environment variable for the CI [PR](https://github.com/alicevision/Meshroom/pull/2492)\n- Adding new tutorial [PR](https://github.com/alicevision/Meshroom/pull/2546)\n- [ci] Use GitHub's workflows for the Windows CI instead of appveyor [PR](https://github.com/alicevision/Meshroom/pull/2551)\n- [ci] Codecov: enable support for test run reports [PR](https://github.com/alicevision/Meshroom/pull/2659)\n- change git clone link to use https link in \"get the project\" [PR](https://github.com/alicevision/Meshroom/pull/2700)\n- [ci] Update Python version from 3.9.13 to 3.11 [PR](https://github.com/alicevision/Meshroom/pull/2758)\n- [docker] Add Dockerfiles for Rocky 9 and handle Qt 6 installation [PR](https://github.com/alicevision/Meshroom/pull/2626)\n- [doc] Update `INSTALL.md` and `README.md` files [PR](https://github.com/alicevision/Meshroom/pull/2787)\n- [build] Fixes for the generation of Meshroom's executable [PR](https://github.com/alicevision/Meshroom/pull/2770)\n- [doc] README.md: Add DeepWiki link, the AI documentation you can talk to [PR](https://github.com/alicevision/Meshroom/pull/2792)\n\n### Contributors\n\n[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)\n\n## Meshroom 2023.3.0 (2023/12/07)\n\nBased on [AliceVision 3.2.0](https://github.com/alicevision/AliceVision/tree/v3.2.0).\n\n### Major Features\n\n- New node for semantic image segmentation [PR](https://github.com/alicevision/Meshroom/pull/2076)\n- Support pixel aspect ratio (no UI) [PR](https://github.com/alicevision/Meshroom/pull/2079)\n- Noise reduction in HDR merging [PR](https://github.com/alicevision/Meshroom/pull/2072)\n\n### Features\n\n- [ui] 2D viewer: image sequence player [PR](https://github.com/alicevision/Meshroom/pull/1989)\n- [bin] meshroom_batch: support multiple init nodes [PR](https://github.com/alicevision/Meshroom/pull/2137)\n- [nodes] StructureFromMotion: Automatic alignment of the 3D reconstruction [PR](https://github.com/alicevision/Meshroom/pull/2199)\n- New node for intrinsics and rig calibration using a multiview acquisition of a checkerboard [PR](https://github.com/alicevision/Meshroom/pull/2171)\n- New Nodal Camera Tracking pipeline [PR](https://github.com/alicevision/Meshroom/pull/2200)\n- Manage LCP in imageProcessing [PR](https://github.com/alicevision/Meshroom/pull/2042)\n- [Viewer3D] Add slider to display cameras based on their resection IDs [PR](https://github.com/alicevision/Meshroom/pull/2235)\n\n### Other Improvements\n\n- Start Development 2023.3 [PR](https://github.com/alicevision/Meshroom/pull/2085)\n- Node to split reconstructed and not reconstructed cameras [PR](https://github.com/alicevision/Meshroom/pull/1974)\n- [core] Execute command line from node folder [PR](https://github.com/alicevision/Meshroom/pull/2093)\n- [core] Add brackets option for GroupAttribute [PR](https://github.com/alicevision/Meshroom/pull/2094)\n- Update Qt version to 5.15.2 [PR](https://github.com/alicevision/Meshroom/pull/1882)\n- [pipelines] Panorama: Publish the panorama preview [PR](https://github.com/alicevision/Meshroom/pull/2106)\n- [nodes] HDR Fusion: Correctly detect the number of brackets when there are several intrinsics [PR](https://github.com/alicevision/Meshroom/pull/2104)\n- [nodes] ImageSegmentation: use ChoiceParam instead of ListAttribute for validClasses [PR](https://github.com/alicevision/Meshroom/pull/2109)\n- [Panorama] Enforce priors after estimation [PR](https://github.com/alicevision/Meshroom/pull/1926)\n- tolerant bracket size selection [PR](https://github.com/alicevision/Meshroom/pull/2113)\n- [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)\n- [nodes] Remove limits on outliers for brackets detection [PR](https://github.com/alicevision/Meshroom/pull/2118)\n- [nodes] LdrToHdrSampling: Exclude outliers from size computation [PR](https://github.com/alicevision/Meshroom/pull/2119)\n- [nodes] HDR Fusion: Select group with largest bracket number in case of equality [PR](https://github.com/alicevision/Meshroom/pull/2121)\n- [nodes] new exportLevels option in PanoramaPostProcessing [PR](https://github.com/alicevision/Meshroom/pull/2133)\n- [ui] GraphEditor: Minor UI changes [PR](https://github.com/alicevision/Meshroom/pull/2125)\n- [pipelines] publish downscaled panorama levels [PR](https://github.com/alicevision/Meshroom/pull/2147)\n- [nodes] HDR Fusion: Use the same bracket detection as in AliceVision [PR](https://github.com/alicevision/Meshroom/pull/2154)\n- AttributeEditor: Flag attributes with invalid values [PR](https://github.com/alicevision/Meshroom/pull/2141)\n- [pipelines] Add colors for CameraTracking and Photog+CamTrack templates [PR](https://github.com/alicevision/Meshroom/pull/2114)\n- [pipelines] add ImageSegmentation node to tracking pipelines [PR](https://github.com/alicevision/Meshroom/pull/2164)\n- Camera exposure update [PR](https://github.com/alicevision/Meshroom/pull/2159)\n- PanoramaInit: remove fake dependency [PR](https://github.com/alicevision/Meshroom/pull/2110)\n- [nodes] Masking: Handle file extensions for masks and mask inversion for `ImageSegmentation` [PR](https://github.com/alicevision/Meshroom/pull/2165)\n- [nodes] KeyframeSelection: Add `minBlockSize` param for multi-threading [PR](https://github.com/alicevision/Meshroom/pull/2161)\n- [nodes] KeyframeSelection: Add support for masks [PR](https://github.com/alicevision/Meshroom/pull/2167)\n- KeyframeSelection: Flag `outputExtension` attribute when it is set to \"none\" for video inputs [PR](https://github.com/alicevision/Meshroom/pull/2163)\n- [blender] apply masks to scene preview [PR](https://github.com/alicevision/Meshroom/pull/2170)\n- Add automatic method for HDR calibration [PR](https://github.com/alicevision/Meshroom/pull/2169)\n- Multiple UI Improvements [PR](https://github.com/alicevision/Meshroom/pull/2173)\n- [ui] FloatImageViewer: adapt resolution to zoom [PR](https://github.com/alicevision/Meshroom/pull/2148)\n- [nodes] StructureFromMotion: Add new `logIntermediateSteps` parameter [PR](https://github.com/alicevision/Meshroom/pull/2182)\n- sfm bootstraping [PR](https://github.com/alicevision/Meshroom/pull/2011)\n- [nodes] PanoramaPostProcessing: Add attributes to change the outputs' names [PR](https://github.com/alicevision/Meshroom/pull/2193)\n- [nodes] Meshing: expose minVis param [PR](https://github.com/alicevision/Meshroom/pull/2196)\n- [ui] SequencePlayer: minor adjustments (fps, icon, play) [PR](https://github.com/alicevision/Meshroom/pull/2197)\n- [pipelines] Rename Nodal Tracking to Nodal Camera Tracking [PR](https://github.com/alicevision/Meshroom/pull/2207)\n- [nodes] DepthMap: increase size of blocks [PR](https://github.com/alicevision/Meshroom/pull/2203)\n- [ui] ImageGallery: Add \"Remove All Images\" menu to clear all images [PR](https://github.com/alicevision/Meshroom/pull/2221)\n- [bin] `meshroom_batch`: Add support for relative input and output paths [PR](https://github.com/alicevision/Meshroom/pull/2218)\n- [pipelines] CamTrack: Add new template without calibration and update some parameters [PR](https://github.com/alicevision/Meshroom/pull/2216)\n- Input color space setting [PR](https://github.com/alicevision/Meshroom/pull/2219)\n- Use new SfmDataEntity plugin instead of AlembicEntity [PR](https://github.com/alicevision/Meshroom/pull/2208)\n- [Viewer3D] Remove AlembicLoader file [PR](https://github.com/alicevision/Meshroom/pull/2228)\n- [pipelines] CamTrack: Update default params for keyframes SfM [PR](https://github.com/alicevision/Meshroom/pull/2227)\n- [pipelines] PhotogAndCamTrack: Disable automatic alignment in SfM [PR](https://github.com/alicevision/Meshroom/pull/2238)\n- Automatic reorientation [PR](https://github.com/alicevision/Meshroom/pull/2236)\n- Minor code clean-up and QML warning and error fixes [PR](https://github.com/alicevision/Meshroom/pull/2226)\n- Add ancestor images info in view [PR](https://github.com/alicevision/Meshroom/pull/2242)\n- [Viewer3D] Connect any change of the selected view ID to the SfmDataLoader [PR](https://github.com/alicevision/Meshroom/pull/2237)\n- New utility nodes to create camera rigs and merge two sfmData [PR](https://github.com/alicevision/Meshroom/pull/2214)\n- [pipelines] Add image segmentation to the Nodal Camera Tracking template [PR](https://github.com/alicevision/Meshroom/pull/2266)\n\n### Bugfixes\n\n- QML: Fix minor coercion error and warning [PR](https://github.com/alicevision/Meshroom/pull/2107)\n- [ScenePreview] fix: 1st chunk was computing all views [PR](https://github.com/alicevision/Meshroom/pull/2108)\n- [bin] meshroom_batch: Save the graph once it has been all set up and resolved [PR](https://github.com/alicevision/Meshroom/pull/2095)\n- [nodes] HDR Fusion: Fix bracket detection [PR](https://github.com/alicevision/Meshroom/pull/2143)\n- [core] Preserve edges by recreating all the nodes during UID evaluation [PR](https://github.com/alicevision/Meshroom/pull/2127)\n- [bin] `meshroom_batch`: Fix input parsing for Windows [PR](https://github.com/alicevision/Meshroom/pull/2188)\n- [nodes] ImageSegmentation: increase GPU requirements [PR](https://github.com/alicevision/Meshroom/pull/2195)\n- [ui] ImageGallery: Disable \"Visualize HDR\" button after clearing images [PR](https://github.com/alicevision/Meshroom/pull/2180)\n- [ui] Check for the existence of the `poses` key in SfM JSON files before accessing it [PR](https://github.com/alicevision/Meshroom/pull/2190)\n- [nodes] CameraInit: fix tooltip focal is in mm [PR](https://github.com/alicevision/Meshroom/pull/2202)\n- [ui] Viewer2D: various orientation fixes [PR](https://github.com/alicevision/Meshroom/pull/2212)\n- [ui] ImageGallery: Use commands to set SfM attributes through the Image Gallery [PR](https://github.com/alicevision/Meshroom/pull/2220)\n- [ui] Preserve last `CameraInit` index when updating the CameraInits list [PR](https://github.com/alicevision/Meshroom/pull/2145)\n- [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)\n- [pipelines] Photogrammetry Draft: Add a `PrepareDenseScene` node to the template [PR](https://github.com/alicevision/Meshroom/pull/2232)\n- [Viewer3D] Bind the display status of the resection groups to QtAliceVision [PR](https://github.com/alicevision/Meshroom/pull/2257)\n- [core] Only update the running chunk to `STOPPED` when stopping computations [PR](https://github.com/alicevision/Meshroom/pull/2258)\n\n### CI, Build and Documentation\n\n- Update build-ubuntu.sh [PR](https://github.com/alicevision/Meshroom/pull/1951)\n- Set `ALICEVISION_SEMANTIC_SEGMENTATION_MODEL` variable during the initialisation [PR](https://github.com/alicevision/Meshroom/pull/2090)\n- [build] Remove references to QmlAlembic in the build process [PR](https://github.com/alicevision/Meshroom/pull/2131)\n\n### Contributors\n\n[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)\n\n\n## Meshroom 2023.2.0 (2023/06/26)\n\nBased on [AliceVision 3.1.0](https://github.com/alicevision/AliceVision/tree/v3.1.0).\n\n### Major Features\n\n- New Photometric Stereo nodes [PR](https://github.com/alicevision/Meshroom/pull/1853)\n- [nodes] New CheckerboardDetection node [PR](https://github.com/alicevision/Meshroom/pull/1869)\n- [nodes] Split360Images: support for SfMData file input and output [PR](https://github.com/alicevision/Meshroom/pull/1939)\n- [sfmTransform] add auto mode [PR](https://github.com/alicevision/Meshroom/pull/1954)\n- [nodes] DepthMap: New option for multi-resolution similarity estimation and optimizations [PR](https://github.com/alicevision/Meshroom/pull/1984)\n- [nodes] Distortion calibration [PR](https://github.com/alicevision/Meshroom/pull/1986)\n- Add a template for the HDR fusion [PR](https://github.com/alicevision/Meshroom/pull/2032)\n- [pipelines] new CameraTracking pipeline [PR](https://github.com/alicevision/Meshroom/pull/2033)\n- [pipelines] new photogrammetry and camera tracking pipeline [PR](https://github.com/alicevision/Meshroom/pull/2041)\n\n### Features\n\n- StructureFromMotion: Add new inputs parameters [PR](https://github.com/alicevision/Meshroom/pull/1980)\n- [panorama] option to build contact sheet [PR](https://github.com/alicevision/Meshroom/pull/1945)\n- Stitching color space [PR](https://github.com/alicevision/Meshroom/pull/1937)\n- Add compression option for exr and jpg images [PR](https://github.com/alicevision/Meshroom/pull/1972)\n- Add rec709 color space options [PR](https://github.com/alicevision/Meshroom/pull/1978)\n- [nodes] rewrite RenderAnimatedCamera [PR](https://github.com/alicevision/Meshroom/pull/2030)\n- [core] Detect and handle UID conflicts when loading a graph [PR](https://github.com/alicevision/Meshroom/pull/2059)\n\n### Other Improvements\n\n- Start Development Version 2023.2.0 [PR](https://github.com/alicevision/Meshroom/pull/1953)\n- [core] Correctly parse status in version names when it exists [PR](https://github.com/alicevision/Meshroom/pull/1966)\n- [tests] TemplatesVersion: Add message when compatibility assertion is raised [PR](https://github.com/alicevision/Meshroom/pull/1964)\n- [ui] add new patterns to load images in viewer2D [PR](https://github.com/alicevision/Meshroom/pull/1975)\n- [nodes] KeyframeSelection: Add support for SfMData files as inputs and outputs [PR](https://github.com/alicevision/Meshroom/pull/1967)\n- [panorama] Panorama preview size [PR](https://github.com/alicevision/Meshroom/pull/1944)\n- add trackbuilder node [PR](https://github.com/alicevision/Meshroom/pull/1987)\n- [submitters] propagate REZ_PROD_PACKAGES_PATH environment variable [PR](https://github.com/alicevision/Meshroom/pull/1992)\n- HDR images naming [PR](https://github.com/alicevision/Meshroom/pull/1999)\n- [nodes] StructureFromMotion: new nbOutliersThreshold attribute [PR](https://github.com/alicevision/Meshroom/pull/2014)\n- [ui] Reflect changes made in QtAliceVision refactorize PR [PR](https://github.com/alicevision/Meshroom/pull/1924)\n- Exposure and format adjustment [PR](https://github.com/alicevision/Meshroom/pull/1983)\n- [nodes] SfMTransform: add alignGround option [PR](https://github.com/alicevision/Meshroom/pull/2020)\n- [nodes] ScenePreview: use base image name for naming output [PR](https://github.com/alicevision/Meshroom/pull/2035)\n- [nodes] KeyframeSelection: Set a dynamic size for the node [PR](https://github.com/alicevision/Meshroom/pull/2039)\n- KeyframeSelection: Add new parameter value to disable the export of keyframes [PR](https://github.com/alicevision/Meshroom/pull/2036)\n- Viewer2D: Dynamically update the list of viewable outputs [PR](https://github.com/alicevision/Meshroom/pull/2044)\n- [ui] ImageGallery: Display the name of the active `CameraInit` group [PR](https://github.com/alicevision/Meshroom/pull/2046)\n- [nodes] StereoPhotometry: Fix some labels and descriptions [PR](https://github.com/alicevision/Meshroom/pull/2034)\n- [ui] Display an icon on nodes that have viewable outputs [PR](https://github.com/alicevision/Meshroom/pull/2047)\n- [ui] Display an icon on nodes that have viewable 3D outputs [PR](https://github.com/alicevision/Meshroom/pull/2052)\n- [pipelines] cameraTracking: change StructureFromMotion parameters [PR](https://github.com/alicevision/Meshroom/pull/2055)\n- [nodes] Harmonize and improve nodes descriptions  [PR](https://github.com/alicevision/Meshroom/pull/2063)\n- [blender] preview: use cycles render engine [PR](https://github.com/alicevision/Meshroom/pull/2064)\n- [blender] preview: occlusions in wireframe shading [PR](https://github.com/alicevision/Meshroom/pull/2071)\n\n### Bugfixes, Build and Documentation\n\n- [doc] RELEASING: Add example command to generate the release note [PR](https://github.com/alicevision/Meshroom/pull/1990)\n- [core] Stats: Retrieve and set the GPU name if it is found [PR](https://github.com/alicevision/Meshroom/pull/1996)\n- [bin] Fix all the scripts that had errors [PR](https://github.com/alicevision/Meshroom/pull/1995)\n- [ui] ImageGallery: Reset viewpoints and intrinsics when removing all the images [PR](https://github.com/alicevision/Meshroom/pull/2031)\n- [nodes] CameraInit: access intrinsic properties safely [PR](https://github.com/alicevision/Meshroom/pull/2040)\n- [blender] preview: handle background image not found [PR](https://github.com/alicevision/Meshroom/pull/2045)\n- Bump requests from 2.22.0 to 2.31.0 [PR](https://github.com/alicevision/Meshroom/pull/2018)\n- [blender] preview: clear loaded images to avoid memory leak [PR](https://github.com/alicevision/Meshroom/pull/2053)\n- Fix submit through simpleFarm [PR](https://github.com/alicevision/Meshroom/pull/2054)\n- [ui] thumbnails: fallback if thumbnailDir could not be created [PR](https://github.com/alicevision/Meshroom/pull/2057)\n- [core] fix transitive reduction when submitting graph [PR](https://github.com/alicevision/Meshroom/pull/2058)\n- [doc] Update readme for custom pipelines and nodes [PR](https://github.com/alicevision/Meshroom/pull/2009)\n- [core] Include the node's type in the UID computation [PR](https://github.com/alicevision/Meshroom/pull/2038)\n- [doc] INSTALL: Add info about the sphere detection model [PR](https://github.com/alicevision/Meshroom/pull/2067)\n- [blender] preview: use Freestyle for line art shading [PR](https://github.com/alicevision/Meshroom/pull/2074)\n- Set `ALICEVISION_SPHERE_DETECTION_MODEL` variable during the initialisation [PR](https://github.com/alicevision/Meshroom/pull/2083)\n\n### Contributors\n\n[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)\n\n\n## Meshroom 2023.1.0 (2023/03/22)\n\nBased on [AliceVision 3.0.0](https://github.com/alicevision/AliceVision/tree/v3.0.0).\n\n### Release Notes Summary\n\n- Major improvements of the depth map quality, performances and scalability. The full resolution can now be computed on most of the standard GPUs.\n- FeatureExtraction is now using DSP-SIFT by default for the 3D Reconstruction pipeline.\n- Capacity to create panoramas with very high resolutions using a limited amount of memory.\n- Enhanced interpretation of RAW images, including new support for Adobe Digital Camera Profile and Lens Camera Profiles databases (if installed on your workstation).\n- Improved color management with OCIO support and more options to export in various colorspaces including ACEScg.\n- New graph templates enabling users to create custom pipelines.\n- Expose a new experimental pipeline for Camera Tracking.\n- Improved GraphEditor with copy-paste and multi-selection.\n- Improved ImageGallery with thumbnails cache and search options.\n- 2D Viewer is now using floating-point images by default.\n- And a very large amount of UI improvements and bug fixes.\n\n### Main Features\n\n- [nodes] DepthMap: depth map improvements [PR](https://github.com/alicevision/Meshroom/pull/1818)\n- Integration of AprilTag library according to issue #1179 and AliceVision pull request #950 [PR](https://github.com/alicevision/Meshroom/pull/1180)\n- [nodes] add gps option to SfMTransform [PR](https://github.com/alicevision/Meshroom/pull/1477)\n- [ui] add support for selecting multiple nodes at once [PR](https://github.com/alicevision/Meshroom/pull/1227)\n- Image Gallery: Add a menu to set the StructureFromMotion initial pair from the gallery [PR](https://github.com/alicevision/Meshroom/pull/1936)\n- Texturing Color Space [PR](https://github.com/alicevision/Meshroom/pull/1933)\n- Add support for Lens Camera Profiles (LCP) [PR](https://github.com/alicevision/Meshroom/pull/1771)\n- RAW advanced processing [PR](https://github.com/alicevision/Meshroom/pull/1918)\n- Add new file watcher behaviours [PR](https://github.com/alicevision/Meshroom/pull/1812)\n- Add internal attributes in \"Notes\" tab [PR](https://github.com/alicevision/Meshroom/pull/1744)\n- New nodes for large memory use in panoramas [PR](https://github.com/alicevision/Meshroom/pull/1819)\n- [ui] Thumbnail cache [PR](https://github.com/alicevision/Meshroom/pull/1861)\n- [nodes] new SfMTriangulation node [PR](https://github.com/alicevision/Meshroom/pull/1842)\n- Color management for RAW images [PR](https://github.com/alicevision/Meshroom/pull/1718)\n- [ui] image gallery search bar [PR](https://github.com/alicevision/Meshroom/pull/1816)\n- [ui] Viewer 2D: enable the HDR viewer by default [PR](https://github.com/alicevision/Meshroom/pull/1793)\n- [ui] Improve the manipulator of the panorama viewer [PR](https://github.com/alicevision/Meshroom/pull/1707)\n- Color space management [PR](https://github.com/alicevision/Meshroom/pull/1792)\n- Show generated images in 2D viewer when double-clicking on node [PR](https://github.com/alicevision/Meshroom/pull/1776)\n- [ui] Elapsed time indicators in log [PR](https://github.com/alicevision/Meshroom/pull/1787)\n- [nodes] SfMTransform: add auto_from_cameras_x_axis [PR](https://github.com/alicevision/Meshroom/pull/1390)\n- Graph Editor: Support copy/paste of selected nodes and scene import [PR](https://github.com/alicevision/Meshroom/pull/1758)\n- [Feature Matching] Add an option to remove matches without enough motion [PR](https://github.com/alicevision/Meshroom/pull/1740)\n- Output in ACES or ACEScg color space [PR](https://github.com/alicevision/Meshroom/pull/1681)\n- Use project files to define pipelines [PR](https://github.com/alicevision/Meshroom/pull/1727)\n- [nodes] StructureFromMotion: Add option computeStructureColor [PR](https://github.com/alicevision/Meshroom/pull/1635)\n- [core] add env var to load nodes from multiple folders [PR](https://github.com/alicevision/Meshroom/pull/1616)\n- Depth map refactoring [PR](https://github.com/alicevision/Meshroom/pull/680)\n- Draft Reconstruction pipeline [PR](https://github.com/alicevision/Meshroom/pull/1489)\n- [ui] Add filters to image gallery [PR](https://github.com/alicevision/Meshroom/pull/1500)\n- [nodes] New node \"RenderAnimatedCamera\" using blender API [PR](https://github.com/alicevision/Meshroom/pull/1432)\n- New node to import known poses for various file formats [PR](https://github.com/alicevision/Meshroom/pull/1475)\n- New ImageMasking and MeshMasking nodes [PR](https://github.com/alicevision/Meshroom/pull/1483)\n- Create Split360Images Node [PR](https://github.com/alicevision/Meshroom/pull/1464)\n- New lens distortion calibration node [PR](https://github.com/alicevision/Meshroom/pull/1403)\n- New experimental camera tracking pipeline [PR](https://github.com/alicevision/Meshroom/pull/1379)\n- [multiview] New pipeline \"Photogrammetry and Camera Tracking\" [PR](https://github.com/alicevision/Meshroom/pull/1429)\n- [nodes] KeyframeSelection: Rework the node and add parameters for new selection methods [PR](https://github.com/alicevision/Meshroom/pull/1880)\n\n\n### Other Improvements\n\n- [nodes] ImageProcessing: Add and hide the fringing correction in the LCP [PR](https://github.com/alicevision/Meshroom/pull/1930)\n- Update highlight mode description in imageProcessing node [PR](https://github.com/alicevision/Meshroom/pull/1928)\n- [ui] Prompt a warning dialog when attempting to submit an unsaved project [PR](https://github.com/alicevision/Meshroom/pull/1927)\n- [panorama] force pyramid levels count in compositing [PR](https://github.com/alicevision/Meshroom/pull/1919)\n- [ui] Add a new advanced menu action to load templates like regular projects [PR](https://github.com/alicevision/Meshroom/pull/1920)\n- [panorama] New option to disable compositing tiling [PR](https://github.com/alicevision/Meshroom/pull/1916)\n- [sfmtransform] Transformation parameter availability [PR](https://github.com/alicevision/Meshroom/pull/1876)\n- Apply DCP metadata in imageProcessing [PR](https://github.com/alicevision/Meshroom/pull/1879)\n- [ui] FeaturesViewer: track endpoints [PR](https://github.com/alicevision/Meshroom/pull/1838)\n- LdrToHdrMerge node: Add a checkbox enabling the manual setting of the reference bracket for HDR merging [PR](https://github.com/alicevision/Meshroom/pull/1849)\n- [ui] Display nodes computed in another Meshroom instance as \"Computed Externally\" [PR](https://github.com/alicevision/Meshroom/pull/1862)\n- [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)\n- [ui] GraphEditor: use maxZoom to fit on nodes [PR](https://github.com/alicevision/Meshroom/pull/1865)\n- [ui] Viewer2D: support all Exif orientation tags [PR](https://github.com/alicevision/Meshroom/pull/1857)\n- Use DCP by default if the database is set and create errors on missing DCP files [PR](https://github.com/alicevision/Meshroom/pull/1863)\n- [ui] Load 3D Depth Map: minor improvements [PR](https://github.com/alicevision/Meshroom/pull/1852)\n- [ui] Checkbox to enable/disable 8-bit viewer [PR](https://github.com/alicevision/Meshroom/pull/1858)\n- Add Ripple submitter [PR](https://github.com/alicevision/Meshroom/pull/1844)\n- [ui] ImageGallery: Increase the GridView's cache capacity [PR](https://github.com/alicevision/Meshroom/pull/1855)\n- [ui] Reorganize the \"File\" menu [PR](https://github.com/alicevision/Meshroom/pull/1856)\n- [nodes] rename: remove \"utils\" from executables names [PR](https://github.com/alicevision/Meshroom/pull/1848)\n- [ui] Integrate QtOIIO into QtAliceVision [PR](https://github.com/alicevision/Meshroom/pull/1831)\n- Add nl means denoising open cv in image processing node [PR](https://github.com/alicevision/Meshroom/pull/1719)\n- [core] Add cgroups support to meshroom [PR](https://github.com/alicevision/Meshroom/pull/1836)\n- Remove support for Python 2 [PR](https://github.com/alicevision/Meshroom/pull/1837)\n- [submitters] Add an option to update the job title on submitters [PR](https://github.com/alicevision/Meshroom/pull/1824)\n- [ui] GraphEditor: create new pipelines with the node menu [PR](https://github.com/alicevision/Meshroom/pull/1833)\n- [bin] meshroom_batch: allow passing list of values to param overrides [PR](https://github.com/alicevision/Meshroom/pull/1811)\n- [ui] ImageGallery: update the Viewer2D correctly when the GridView's current item changes [PR](https://github.com/alicevision/Meshroom/pull/1823)\n- [ui] keyboard shortcut: press tab to open node menu [PR](https://github.com/alicevision/Meshroom/pull/1813)\n- Update bounding box display to use the correct geometric frame [PR](https://github.com/alicevision/Meshroom/pull/1805)\n- [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)\n- Use most recent project as base folder for file dialogs [PR](https://github.com/alicevision/Meshroom/pull/1778)\n- [ui] Restrain the \"copy/paste nodes\" shortcuts to the GraphEditor [PR](https://github.com/alicevision/Meshroom/pull/1782)\n- [core] Set the \"template\" flag to \"false\" when saving a project as a regular file [PR](https://github.com/alicevision/Meshroom/pull/1777)\n- [ui] Display computation time for \"running\" or \"finished\" nodes [PR](https://github.com/alicevision/Meshroom/pull/1764)\n- Removed duplicated call to findnodes [PR](https://github.com/alicevision/Meshroom/pull/1767)\n- Add dedicated \"minimal\" mode for templates [PR](https://github.com/alicevision/Meshroom/pull/1754)\n- [ui] Reduce confusion when qml loading fails [PR](https://github.com/alicevision/Meshroom/pull/1728)\n- [ui] Update intrinsics table when switching between groups [PR](https://github.com/alicevision/Meshroom/pull/1755)\n- [bin] batch: allow to set params inside groups [PR](https://github.com/alicevision/Meshroom/pull/1665)\n- [camerainit] update parameters to use focal in mm [PR](https://github.com/alicevision/Meshroom/pull/1652)\n- [bin] newNodeType: update [PR](https://github.com/alicevision/Meshroom/pull/1630)\n- [minor] renderfarm submission with rez [PR](https://github.com/alicevision/Meshroom/pull/1629)\n- [ui] widgets visibility options [PR](https://github.com/alicevision/Meshroom/pull/1545)\n- [bin] Avoid multi-threading in non-interactive computation [PR](https://github.com/alicevision/Meshroom/pull/1553)\n- [nodes] Mesh*: use file extension to choose the file format [PR](https://github.com/alicevision/Meshroom/pull/1524)\n- Upgrade Texturing node and add multiples mesh file types [PR](https://github.com/alicevision/Meshroom/pull/1508)\n- Optical center relative to the image center [PR](https://github.com/alicevision/Meshroom/pull/1509)\n- [core] Improve project files upgrade [PR](https://github.com/alicevision/Meshroom/pull/1503)\n- [ui] Add a clear images button  [PR](https://github.com/alicevision/Meshroom/pull/1467)\n- [ui] highlight the edge that will be deleted [PR](https://github.com/alicevision/Meshroom/pull/1434)\n- Update 2d viewer for new Track drawing mode of QtAliceVision  [PR](https://github.com/alicevision/Meshroom/pull/1435)\n- Add cli script to start Meshroom on Windows [PR](https://github.com/alicevision/Meshroom/pull/1169)\n- Allow replacing edges [PR](https://github.com/alicevision/Meshroom/pull/1355)\n- No cmd line range arguments if we have only a single chunk [PR](https://github.com/alicevision/Meshroom/pull/1426)\n- [nodes] ExportAnimatedCameras: new sfmDataFilter parameter [PR](https://github.com/alicevision/Meshroom/pull/1428)\n- Node highlight radius [PR](https://github.com/alicevision/Meshroom/pull/1357)\n\n### Bug Fixes, Build and Documentation\n\n- [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)\n- [ui] ImageGallery: Allow image drop if the active group is not computing [PR](https://github.com/alicevision/Meshroom/pull/1941)\n- [ui] Viewer2D: fix displayed metadata [PR](https://github.com/alicevision/Meshroom/pull/1915)\n- [setup] add all scripts in bin/ as executables [PR](https://github.com/alicevision/Meshroom/pull/1419)\n- Add a unit test to check the node versions of templates [PR](https://github.com/alicevision/Meshroom/pull/1799)\n- [nodes] Split360Images: update attributes to software version 2.0 [PR](https://github.com/alicevision/Meshroom/pull/1935)\n- [ci] upgrade github actions rules [PR](https://github.com/alicevision/Meshroom/pull/1834)\n- Update INSTALL.md [PR](https://github.com/alicevision/Meshroom/pull/1803)\n- [docs] Python documentation generation using Sphinx [PR](https://github.com/alicevision/Meshroom/pull/1794)\n- Documentation update : how to use Meshroom without building AliceVision [PR](https://github.com/alicevision/Meshroom/pull/1487)\n- [pipelines] Panorama: Fix inputs of the \"Publish\" nodes [PR](https://github.com/alicevision/Meshroom/pull/1922)\n- [nodes] ExportAnimatedCameras: fix output params labels [PR](https://github.com/alicevision/Meshroom/pull/1911)\n- [nodes] PanoramaWarping: remove obsolete image output attributes [PR](https://github.com/alicevision/Meshroom/pull/1914)\n- Fix the documentation related to Panorama nodes [PR](https://github.com/alicevision/Meshroom/pull/1917)\n- Fix missing Publish nodes in templates [PR](https://github.com/alicevision/Meshroom/pull/1903)\n- [ui] Intrinsics: Fix warnings and exceptions [PR](https://github.com/alicevision/Meshroom/pull/1898)\n- [ui] fix thumbnail cache bugs [PR](https://github.com/alicevision/Meshroom/pull/1893)\n- [ImageGallery] Match the filter selection with the gallery's display [PR](https://github.com/alicevision/Meshroom/pull/1899)\n- [ui] fix \"Sync Camera with Image Selection\" [PR](https://github.com/alicevision/Meshroom/pull/1888)\n- 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)\n- fix(sec): upgrade psutil to 5.6.7 [PR](https://github.com/alicevision/Meshroom/pull/1843)\n- [ui] Fix all \"TypeError\" QML warnings [PR](https://github.com/alicevision/Meshroom/pull/1839)\n- [ui] Viewer2D: fix minor issues [PR](https://github.com/alicevision/Meshroom/pull/1829)\n- Fix crash when importing images with non-ascii characters in their filepath [PR](https://github.com/alicevision/Meshroom/pull/1809)\n- Fix and prevent mismatches between an attribute's type and its default value's type [PR](https://github.com/alicevision/Meshroom/pull/1784)\n- Fix various typos [PR](https://github.com/alicevision/Meshroom/pull/1768)\n- [ui] ImageGallery: fix some minor issues [PR](https://github.com/alicevision/Meshroom/pull/1766)\n- [core] fix logging of nodes loading [PR](https://github.com/alicevision/Meshroom/pull/1748)\n- Fix node duplication/removal behaviour [PR](https://github.com/alicevision/Meshroom/pull/1738)\n- [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)\n- Fix compatibility with Python 3 [PR](https://github.com/alicevision/Meshroom/pull/1734)\n- Fix stats [PR](https://github.com/alicevision/Meshroom/pull/1704)\n- [ui] ImageGallery: fix missing function changeCurrentIndex [PR](https://github.com/alicevision/Meshroom/pull/1679)\n- [UI] StatViewer: fix displayed unit [PR](https://github.com/alicevision/Meshroom/pull/1547)\n- [ui] fix uvCenterOffset [PR](https://github.com/alicevision/Meshroom/pull/1551)\n- Fix meshroom_batch [PR](https://github.com/alicevision/Meshroom/pull/1521)\n- Fix incompatibility with recent cx_Freeze [PR](https://github.com/alicevision/Meshroom/pull/1480)\n- [bin] meshroom_batch: fix typo in pipeline names [PR](https://github.com/alicevision/Meshroom/pull/1377)\n- Removing `io_counters` from the ProcStatatistics [PR](https://github.com/alicevision/Meshroom/pull/1374)\n- Fix NameError [PR](https://github.com/alicevision/Meshroom/pull/1312)\n- [ui] Image Gallery: Fix the display of the intrinsics table with temporary CameraInit nodes [PR](https://github.com/alicevision/Meshroom/pull/1934)\n- [ui] Correctly update the Viewer 2D when there are temporary CameraInit nodes [PR](https://github.com/alicevision/Meshroom/pull/1931)\n- [ui] Clear Images: Request a graph update after resetting the viewpoints and intrinsics [PR](https://github.com/alicevision/Meshroom/pull/1929)\n- [ui] Improve \"Clear Images\" action's behaviour and performance [PR](https://github.com/alicevision/Meshroom/pull/1897)\n- [Viewer] Load and unload the SfMStats components explicitly every time they are shown and hidden  [PR](https://github.com/alicevision/Meshroom/pull/1912)\n- [ui] Drag&Drop: Use a pool of threads for asynchronous intrinsics computations [PR](https://github.com/alicevision/Meshroom/pull/1896)\n- [nodes] CameraInit: upgrade version following the parameters changes [PR](https://github.com/alicevision/Meshroom/pull/1874)\n- [ui] app: temporary workaround for qInstallMessageHandler [PR](https://github.com/alicevision/Meshroom/pull/1873)\n- [ui] ImageGallery: fix the DB path in the \"Edit Sensor Database\" dialog [PR](https://github.com/alicevision/Meshroom/pull/1860)\n- [ui] Correctly determine if a graph is being computed locally and update nodes' statuses accordingly [PR](https://github.com/alicevision/Meshroom/pull/1832)\n- [nodes] CameraInit: all intrinsics parameters should invalidate [PR](https://github.com/alicevision/Meshroom/pull/1747)\n- [ci] add bug to the list of tag to skip the stale check [PR](https://github.com/alicevision/Meshroom/pull/1745)\n- Fix various typos in the source code [PR](https://github.com/alicevision/Meshroom/pull/1606)\n- Update ion startup [PR](https://github.com/alicevision/Meshroom/pull/1815)\n- New script to launch meshroom under ion environment [PR](https://github.com/alicevision/Meshroom/pull/1783)\n- [doc] fix the bibtex [PR](https://github.com/alicevision/Meshroom/pull/1537)\n- [doc] readme: add citation [PR](https://github.com/alicevision/Meshroom/pull/1520)\n\n### Contributors\n\nThanks 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.\n\nOther release contributors:\n[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)\n\n\n\n## Release 2021.1.0 (2021/02/26)\n\nBased on [AliceVision 2.4.0](https://github.com/alicevision/AliceVision/tree/v2.4.0).\n\n### Release Notes Summary\n\n - [panorama] PanoramaCompositing: new algorithm with tiles to deal with large panoramas [PR](https://github.com/alicevision/meshroom/pull/1173)\n - [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)\n - [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)\n - [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)\n - [nodes] MeshFiltering: smoothing & filtering on subset of the geometry [PR](https://github.com/alicevision/meshroom/pull/1272)\n - [ui] Viewer: fix gain/gamma behavior and use non-linear sliders [PR](https://github.com/alicevision/meshroom/pull/1092)\n\n### Other Improvements and Bug Fixes\n\n - [core] taskManager: downgrade status per chunk [PR](https://github.com/alicevision/meshroom/pull/1210)\n - [core] Improve graph dependencies: dependencies to an input parameter is not a real dependency [PR](https://github.com/alicevision/meshroom/pull/1182)\n - [nodes] Meshing: Add `addMaskHelperPoints` option [PR](https://github.com/alicevision/meshroom/pull/1273)\n - [nodes] Meshing: More control on graph cut post processing [PR](https://github.com/alicevision/meshroom/pull/1284)\n - [nodes] Meshing: new cells filtering by solid angle ratio [PR](https://github.com/alicevision/meshroom/pull/1274)\n - [nodes] Meshing: add seed and voteFilteringForWeaklySupportedSurfaces [PR](https://github.com/alicevision/meshroom/pull/1268)\n - [nodes] Add some mesh utilities nodes [PR](https://github.com/alicevision/meshroom/pull/1271)\n - [nodes] SfmTransform: new from_center_camera [PR](https://github.com/alicevision/meshroom/pull/1281)\n - [nodes] Panorama: new options to init with known poses [PR](https://github.com/alicevision/meshroom/pull/1230)\n - [nodes] FeatureMatching: add cross verification [PR](https://github.com/alicevision/meshroom/pull/1276)\n - [nodes] ExportAnimatedCamera: New option to export undistort maps in EXR format [PR](https://github.com/alicevision/meshroom/pull/1229)\n - [nodes] new wip node `LightingEstimation` to estimate spherical harmonics from normal map and albedo [PR](https://github.com/alicevision/meshroom/pull/390)\n - [nodes] CameraInit: add a boolean for white balance use [PR](https://github.com/alicevision/meshroom/pull/1162)\n - [ui] fix error on live reconstruction [PR](https://github.com/alicevision/meshroom/pull/1145)\n - [ui] init saveAs folder [PR](https://github.com/alicevision/meshroom/pull/1099)\n - [ui] add link to online documentation in 'Help' menu [PR](https://github.com/alicevision/meshroom/pull/1279)\n - [ui] New node menu categories [PR](https://github.com/alicevision/meshroom/pull/1278)\n\n\n## Release 2020.1.1 (2020/10/14)\n\nBased on [AliceVision 2.3.1](https://github.com/alicevision/AliceVision/tree/v2.3.1).\n\n - [core] Fix crashes on process statistics (windows-only) [PR](https://github.com/alicevision/meshroom/pull/1096)\n\n\n## Release 2020.1.0 (2020/10/09)\n\nBased on [AliceVision 2.3.0](https://github.com/alicevision/AliceVision/tree/v2.3.0).\n\n### Release Notes Summary\n\n - [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)\n - [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)\n - [ui] Viewer3D: Input bounding box (Meshing) & manual transformation (SfMTransform) thanks to a new 3D Gizmo [PR](https://github.com/alicevision/meshroom/pull/978)\n - [ui] Sync 3D camera with image selection [PR](https://github.com/alicevision/meshroom/pull/633) \n - [ui] New HDR (floating point) Image Viewer [PR](https://github.com/alicevision/meshroom/pull/795)\n - [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) \n - [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)\n - [ui] Add SfM statistics [PR](https://github.com/alicevision/meshroom/pull/873)\n - [ui] Visual interface for node resources usage [PR](https://github.com/alicevision/meshroom/pull/564)\n - [nodes] Coordinate system alignment to specific markers or between scenes [PR](https://github.com/alicevision/meshroom/pull/652)\n - [nodes] New Sketchfab upload node [PR](https://github.com/alicevision/meshroom/pull/712)\n - [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)\n - [ui] Viewer: add Camera Response Function display [PR](https://github.com/alicevision/meshroom/pull/1020) [PR](https://github.com/alicevision/meshroom/pull/1041)\n - [ui] UI improvements in the Viewer2D and ImageGallery [PR](https://github.com/alicevision/meshroom/pull/823)\n - [bin] Improve Meshroom command line [PR](https://github.com/alicevision/meshroom/pull/759) [PR](https://github.com/alicevision/meshroom/pull/632)\n - [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)\n - [nodes] `FeatureMatching` Add `fundamental_with_distortion` option [PR](https://github.com/alicevision/meshroom/pull/931)\n - [multiview] Declare more recognized image file extensions [PR](https://github.com/alicevision/meshroom/pull/965)\n - [multiview] More generic metadata support [PR](https://github.com/alicevision/meshroom/pull/957)\n\n### Other Improvements and Bug Fixes\n\n - [nodes] CameraInit: New viewId generation and selection of allowed intrinsics [PR](https://github.com/alicevision/meshroom/pull/973)\n - [core] Avoid error during project load on border cases [PR](https://github.com/alicevision/meshroom/pull/991)\n - [core] Compatibility : Improve list of groups update [PR](https://github.com/alicevision/meshroom/pull/791)\n - [core] Invalidation hooks [PR](https://github.com/alicevision/meshroom/pull/732)\n - [core] Log manager for Python based nodes [PR](https://github.com/alicevision/meshroom/pull/631)\n - [core] new Node Update Hooks mechanism [PR](https://github.com/alicevision/meshroom/pull/733)\n - [core] Option to make chunks optional [PR](https://github.com/alicevision/meshroom/pull/778)\n - [nodes] Add methods in ImageMatching and features in StructureFromMotion and FeatureMatching [PR](https://github.com/alicevision/meshroom/pull/768)\n - [nodes] FeatureExtraction: add maxThreads argument [PR](https://github.com/alicevision/meshroom/pull/647) \n - [nodes] Fix python nodes being blocked by log [PR](https://github.com/alicevision/meshroom/pull/783)\n - [nodes] ImageProcessing: add new option to fix non finite pixels [PR](https://github.com/alicevision/meshroom/pull/1057)\n - [nodes] Meshing: simplify input depth map folders [PR](https://github.com/alicevision/meshroom/pull/951)\n - [nodes] PanoramaCompositing: add a new graphcut option to improve seams [PR](https://github.com/alicevision/meshroom/pull/1026)\n - [nodes] PanoramaCompositing: option to select the percentage of upscaled pixels [PR](https://github.com/alicevision/meshroom/pull/1049)\n - [nodes] PanoramaInit: add debug circle detection option [PR](https://github.com/alicevision/meshroom/pull/1069)\n - [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)\n - [nodes] SfmTransfer: New option to transfer intrinsics parameters [PR](https://github.com/alicevision/meshroom/pull/1053)\n - [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)\n - [nodes] Texturing: add options for retopoMesh & reorganise options [PR](https://github.com/alicevision/meshroom/pull/571)\n - [nodes] Texturing: put downscale to 2 by default [PR](https://github.com/alicevision/meshroom/pull/1048)\n - [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)\n - [ui] Automatically update layout when needed [PR](https://github.com/alicevision/meshroom/pull/989)\n - [ui] Avoid crash in 3D with large panoramas [PR](https://github.com/alicevision/meshroom/pull/1061)\n - [ui] Fix graph axes naming for ram statistics [PR](https://github.com/alicevision/meshroom/pull/1033)\n - [ui] NodeEditor: minor improvements with single tab group and status table [PR](https://github.com/alicevision/meshroom/pull/637)\n - [ui] Viewer3D: Display equirectangular images as environment maps [PR](https://github.com/alicevision/meshroom/pull/731) \n - [windows] Fix open recent broken on windows and remove unnecessary warnings [PR](https://github.com/alicevision/meshroom/pull/940)\n\n### Build, CI, Documentation\n\n - [build] Fix cxFreeze version for Python 2.7 compatibility [PR](https://github.com/alicevision/meshroom/pull/634)\n - [ci] Add github Actions [PR](https://github.com/alicevision/meshroom/pull/1051)\n - [ci] AppVeyor: Update build environment and save artifacts [PR](https://github.com/alicevision/meshroom/pull/875)\n - [ci] Travis: Update environment, remove Python 2.7 & add 3.8 [PR](https://github.com/alicevision/meshroom/pull/874)\n - [docker] Clean Dockerfiles [PR](https://github.com/alicevision/meshroom/pull/1054)\n - [docker] Move to PySide2 / Qt 5.14.1\n - [docker] Fix some packaging issues of the release 2019.2.0 [PR](https://github.com/alicevision/meshroom/pull/627)\n - [github] Add exemptLabels [PR](https://github.com/alicevision/meshroom/pull/801)\n - [github] Add issue templates [PR](https://github.com/alicevision/meshroom/pull/579)\n - [github] Add template for questions / help only  [PR](https://github.com/alicevision/meshroom/pull/629)\n - [github] Added automatic stale detection and closing for issues [PR](https://github.com/alicevision/meshroom/pull/598)\n - [python] Import ABC from collections.abc [PR](https://github.com/alicevision/meshroom/pull/983)\n\nFor more details see all PR merged: https://github.com/alicevision/meshroom/milestone/10\n\nSee [AliceVision 2.3.0 Release Notes](https://github.com/alicevision/AliceVision/blob/v2.3.0/CHANGES.md) for more details about algorithmic changes.\n\n\n## Release 2019.2.0 (2019/08/08)\n\nBased on [AliceVision 2.2.0](https://github.com/alicevision/AliceVision/tree/v2.2.0).\n\nRelease Notes Summary:\n\n - Visualisation: New visualization module of the features extraction. [PR](https://github.com/alicevision/meshroom/pull/539), [New QtAliceVision](https://github.com/alicevision/QtAliceVision)\n - Support for RAW image files.\n - Texturing: Largely improve the Texturing quality.\n - Texturing: Speed improvements.\n - Texturing: Add support for UDIM.\n - Meshing: Export the dense point cloud in Alembic.\n - Meshing: New option to export the full raw dense point cloud (with all 3D points candidates before cut and filtering).\n - Meshing: Adds an option to export color data per vertex and MeshFiltering correctly preserves colors.\n\nFull Release Notes:\n\n - Move to PySide2 / Qt 5.13\n - 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).\n - 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)\n - Viewer3D: Add support for vertex-colored meshes. [PR](https://github.com/alicevision/meshroom/pull/550)\n - 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)\n - New arguments to meshroom. [PR](https://github.com/alicevision/meshroom/pull/413)\n - HDR: New HDR module for the fusion of multiple LDR images.\n - PrepareDenseScene: Add experimental option to correct Exposure Values (EV) of input images to uniformize dataset exposures.\n - FeatureExtraction: Include CCTag in the release binaries both on Linux and Windows.\n - 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.\n\nFor more details see all PR merged: https://github.com/alicevision/meshroom/milestone/9\nSee [AliceVision 2.2.0 Release Notes](https://github.com/alicevision/AliceVision/blob/v2.2.0/CHANGES.md)\nfor more details about algorithmic changes.\n\n\n## Release 2019.1.0 (2019/02/27)\n\nBased on [AliceVision 2.1.0](https://github.com/alicevision/AliceVision/tree/v2.1.0).\n\nRelease Notes Summary:\n - 3D Viewer: Load and compare multiple assets with cache mechanism and improved navigation\n - Display camera intrinsic information extracted from metadata analysis\n - Easier access to a more complete sensor database with a more reliable camera model matching algorithm.\n - 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.\n - Graph Editor: Improved set of contextual tools with `duplicate`/`remove`/`delete data` actions with `From Here` option.\n - Nodes: Homogenization of inputs / outputs parameters\n - 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.\n - 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.\n - MeshFiltering: Now keeps all reconstructed parts by default.\n - StructureFromMotion: Add support for rig of cameras\n - Support for reconstruction with projected light patterns and texturing with another set of images\n\nFull Release Notes:\n - Viewer3D: New Trackball camera manipulator for improved navigation in the scene\n - Viewer3D: New library system to load multiple 3D objects of the same type simultaneously, simplifying results comparisons\n - Viewer3D: Add media loading overlay with BusyIndicator\n - Viewer3D: Points and cameras size are now configurable via dedicated sliders.\n - CameraInit: Add option to lock specific cameras intrinsics (if you have high-quality internal calibration information)\n - StructureFromMotion: Triangulate points if the input scene contains valid camera poses and intrinsics without landmarks\n - 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.\n - NodeLog: Cross-platform monospace display\n - Remove `CameraConnection` and `ExportUndistortedImages` nodes\n - Multi-machine parallelization of `PrepareDenseScene`\n - Meshing: Add option `estimateSpaceFromSfM` and observation angles check to better estimate the bounding box of the reconstruction and avoid useless reconstruction of the environment\n - Console: Filter non silenced, inoffensive warnings from QML + log Qt messages via Python logging\n - Command line (meshroom_photogrammetry): Add --pipeline parameter to use a pre-configured pipeline graph\n - Command line (meshroom_photogrammetry): Add possibility to provide pre-calibrated intrinsics.\n - Command line (meshroom_compute): Provide `meshroom_compute` executable in packaged release.\n - Image Gallery: Display Camera Intrinsics initialization status with detailed explanation, edit Sensor Database dialog, advanced menu to display view UIDs\n - StructureFromMotion: Expose advanced estimator parameters\n - FeatureMatching: Expose advanced estimator parameters\n - DepthMap: New option `exportIntermediateResults` disabled by default, so less data storage by default than before.\n - DepthMap: Use multiple GPUs by default if available and add `nbGPUs` param to limit it\n - Meshing: Add option `addLandmarksToTheDensePointCloud`\n - SfMTransform: New option to align on one specific camera\n - Graph Editor: Consistent read-only mode when computing, that can be unlocked in advanced settings\n - Graph Editor: Improved Node Menu: \"duplicate\"/\"remove\"/\"delete data\" with \"From Here\" accessible on the same entry via an additional button\n - Graph Editor: Confirmation popup before deleting node data\n - Graph Editor: Add \"Clear Pending Status\" action at Graph level\n - Graph Editor: Solo media in 3D viewer with Ctrl + double click on node/attribute\n - Param Editor: Fix several bugs related to attributes edition\n - 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.\n\nSee [AliceVision 2.1.0 Release Notes](https://github.com/alicevision/AliceVision/blob/v2.1.0/CHANGES.md)\nfor more details about algorithmic changes.\n\n\n## Release 2018.1.0 (2018.08.09)\n\n First release of Meshroom.  \n Based on [AliceVision 2.0.0](https://github.com/alicevision/AliceVision/tree/v2.0.0).\n"
  },
  {
    "path": "CMakeLists.txt",
    "content": "cmake_minimum_required(VERSION 3.12)\nproject(meshroom LANGUAGES C CXX)\n\nif(NOT CMAKE_BUILD_TYPE)\n  set(CMAKE_BUILD_TYPE Release CACHE STRING \"Build type for Meshroom plugins\" FORCE)\nendif()\n\nset(ALICEVISION_ROOT \"$ENV{ALICEVISION_ROOT}\" CACHE STRING \"AliceVision root dir\")\nset(QT_DIR \"$ENV{QT_DIR}\" CACHE STRING \"Qt root directory\")\n\noption(MR_BUILD_QTALICEVISION \"Enable building of QtAliceVision plugin\" ON)\n\nif(CMAKE_BUILD_TYPE MATCHES Release)\n    message(STATUS \"Force CMAKE_INSTALL_DO_STRIP in Release\")\n    set(CMAKE_INSTALL_DO_STRIP ON)\nelse()\n    set(CMAKE_INSTALL_DO_STRIP OFF)\nendif()\n\nset(CMAKE_CORE_BUILD_FLAGS -DCMAKE_BUILD_TYPE=${CMAKE_BUILD_TYPE} -DBUILD_SHARED_LIBS:BOOL=ON -DCMAKE_INSTALL_DO_STRIP=${CMAKE_INSTALL_DO_STRIP})\n\nset(ALEMBIC_CMAKE_FLAGS\n  -DAlembic_DIR:PATH=${ALICEVISION_ROOT}/lib/cmake/Alembic\n  -DImath_DIR=${ALICEVISION_ROOT}/lib/cmake/Imath\n)\n\n\ninclude(ExternalProject)\n# ==============================================================================\n# GNUInstallDirs CMake module\n# - Define GNU standard installation directories\n# - Provides install directory variables as defined by the GNU Coding Standards.\n# ==============================================================================\ninclude(GNUInstallDirs)\n\n# message(STATUS \"QT_CMAKE_FLAGS: ${QT_CMAKE_FLAGS}\")\n\nif(MR_BUILD_QTALICEVISION)\nset(QTALICEVISION_TARGET QtAliceVision)\nExternalProject_Add(${QTALICEVISION_TARGET}\n      GIT_REPOSITORY https://github.com/alicevision/QtAliceVision\n      GIT_TAG develop\n      PREFIX ${BUILD_DIR}\n      BUILD_IN_SOURCE 0\n      BUILD_ALWAYS 0\n      SOURCE_DIR ${CMAKE_CURRENT_BINARY_DIR}/QtAliceVision\n      BINARY_DIR ${BUILD_DIR}/QtAliceVision_build\n      INSTALL_DIR ${CMAKE_INSTALL_PREFIX}\n      CONFIGURE_COMMAND ${CMAKE_COMMAND} ${CMAKE_CORE_BUILD_FLAGS} ${ALEMBIC_CMAKE_FLAGS} -DCMAKE_PREFIX_PATH:PATH=${QT_DIR}$<SEMICOLON>${ALICEVISION_ROOT} -DCMAKE_INSTALL_PREFIX:PATH=<INSTALL_DIR> <SOURCE_DIR>\n      )\nendif()\n\n"
  },
  {
    "path": "CODE_OF_CONDUCT.md",
    "content": "# Contributor Covenant Code of Conduct\n\n## Our Pledge\n\nIn the interest of fostering an open and welcoming environment, we as\ncontributors and maintainers pledge to making participation in our project and\nour community a harassment-free experience for everyone, regardless of age, body\nsize, disability, ethnicity, gender identity and expression, level of experience,\nnationality, personal appearance, race, religion, or sexual identity and\norientation.\n\n## Our Standards\n\nExamples of behavior that contributes to creating a positive environment\ninclude:\n\n* Using welcoming and inclusive language\n* Being respectful of differing viewpoints and experiences\n* Gracefully accepting constructive criticism\n* Focusing on what is best for the community\n* Showing empathy towards other community members\n\nExamples of unacceptable behavior by participants include:\n\n* The use of sexualized language or imagery and unwelcome sexual attention or\nadvances\n* Trolling, insulting/derogatory comments, and personal or political attacks\n* Public or private harassment\n* Publishing others' private information, such as a physical or electronic\n  address, without explicit permission\n* Other conduct which could reasonably be considered inappropriate in a\n  professional setting\n\n## Our Responsibilities\n\nProject maintainers are responsible for clarifying the standards of acceptable\nbehavior and are expected to take appropriate and fair corrective action in\nresponse to any instances of unacceptable behavior.\n\nProject maintainers have the right and responsibility to remove, edit, or\nreject comments, commits, code, wiki edits, issues, and other contributions\nthat are not aligned to this Code of Conduct, or to ban temporarily or\npermanently any contributor for other behaviors that they deem inappropriate,\nthreatening, offensive, or harmful.\n\n## Scope\n\nThis Code of Conduct applies both within project spaces and in public spaces\nwhen an individual is representing the project or its community. Examples of\nrepresenting a project or community include using an official project e-mail\naddress, posting via an official social media account, or acting as an appointed\nrepresentative at an online or offline event. Representation of a project may be\nfurther defined and clarified by project maintainers.\n\n## Enforcement\n\nInstances of abusive, harassing, or otherwise unacceptable behavior may be\nreported by contacting the project team privately at alicevision-team@googlegroups.com. All\ncomplaints will be reviewed and investigated and will result in a response that\nis deemed necessary and appropriate to the circumstances. The project team is\nobligated to maintain confidentiality with regard to the reporter of an incident.\nFurther details of specific enforcement policies may be posted separately.\n\nProject maintainers who do not follow or enforce the Code of Conduct in good\nfaith may face temporary or permanent repercussions as determined by other\nmembers of the project's leadership.\n\n## Attribution\n\nThis Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4,\navailable at https://www.contributor-covenant.org/version/1/4/code-of-conduct/\n\n[homepage]: https://www.contributor-covenant.org\n"
  },
  {
    "path": "CONTRIBUTING.md",
    "content": "Contributing to Meshroom\n===========================\n\nMeshroom relies on a friendly and community-driven effort to create an open source photogrammetry solution.\nIn order to foster a friendly atmosphere where technical collaboration can flourish,\nwe recommend you to read the [code of conduct](CODE_OF_CONDUCT.md).\n\n# ![Contributing](/docs/logo/contributing.png)\n\nContributing Workflow\n---------------------\n\nThe contributing workflow relies on [Github Pull Requests](https://help.github.com/articles/using-pull-requests/).\n\n1. If it is an important change, we recommend you to discuss it on the mailing-list\nbefore starting implementation. This ensure that the development is aligned with other\ndeveloppements already started and will be efficiently integrated.\n\n2. Create the corresponding issues.\n\n3. 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.\nExplain the implementation in the PR description with links to issues.\n\n4. Implement the new feature(s). Add unit test if needed.\nOne feature per PR is ideal for review, but linked features can be part of the same PR.\n\n5. 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).\n\n6. The reviewers will look over the code and ask for changes, explain problems they found,\ncongratulate the author, etc. using the github comments.\n\n7. After approval, one of the developers with commit approval to the official main repository\nwill merge your fixes into the \"develop\" branch.\n"
  },
  {
    "path": "COPYING.md",
    "content": "## Meshroom License\n\nMeshroom is licensed under the [MPL2 license](LICENSE-MPL2.md).\n\n## Third parties licenses\n\n * __AliceVision__  \n   [https://github.com/alicevision/AliceVision](https://github.com/alicevision/AliceVision)  \n   Copyright (c) 2018 AliceVision contributors.  \n   Distributed under the [MPL2 license](https://opensource.org/licenses/MPL-2.0).\n   See [COPYING](https://github.com/alicevision/AliceVision/blob/develop/COPYING.md) for full third parties licenses.\n\n * __Python__  \n   [https://www.python.org](https://www.python.org)  \n   Copyright (c) 2001-2018 Python Software Foundation.  \n   Distributed under the [PSFL V2 license](https://www.python.org/download/releases/2.7/license/).\n\n * __Qt/PySide6__\n   [https://www.qt.io](https://www.qt.io)  \n   Copyright (C) 2018 The Qt Company Ltd and other contributors.  \n   Distributed under the [LGPL V3 license](https://opensource.org/licenses/LGPL-3.0).\n\n * __QtAliceVision__  \n   [https://github.com/alicevision/QtAliceVision](https://github.com/alicevision/QtAliceVision)  \n   Copyright (c) 2018 AliceVision contributors.  \n   Distributed under the [MPL2 license](https://opensource.org/licenses/MPL-2.0).\n"
  },
  {
    "path": "INSTALL.md",
    "content": "# Meshroom Installation\nThis guide will help you setup a development environment to launch and contribute to Meshroom.\n\n## Table of Contents\n\n1. [Use prebuilt release](#use-prebuilt-release)\n2. [Installation from source code](#installation-from-source-code)\n    1. [Install minimal dependencies](#install-minimal-dependencies)\n        1. [Python environment](#python-environment)\n        2. [Qt/PySide](#qtpyside)\n    2. [Install dependencies](#install-dependencies)\n        1. [AliceVision](#alicevision)\n        2. [QtAliceVision](#qtalicevision)\n    3. [Install plugins](#install-plugins)\n        1. [mrSegmentation plugin](#mrsegmentation-plugin)\n        2. [MeshroomHub](#meshroomhub)\n    4. [Start Meshroom](#start-meshroom)\n3. [Adding custom nodes, templates and plugins](#adding-custom-nodes-templates-and-plugins)\n    1. [Custom nodes](#custom-nodes)\n    2. [Custom templates](#custom-templates)\n    3. [Custom plugins](#custom-plugins)\n\n\n## Use prebuilt release \n\nTo quickly run Meshroom without setting up a development environment, follow these simple steps:\n\n1. **Download the prebuilt binaries**:\n    * Visit the [Releases](https://github.com/alicevision/meshroom/releases) page.\n    * Download the latest release that is suitable for your operating system.\n2. **Extract the archive**:\n    * On Windows: right-click on the .zip file and select \"Extract All\", or run `unzip Meshroom-x.y.z.zip` in a terminal.\n    * On Linux: in a terminal, run `tar -xzvf Meshroom.x.y.z.tar.gz`.\n3. **Run Meshroom**: in the extracted folder, double-click on the \"Meshroom\" executable to launch it.\n\n## Installation from source code\n\nGet the source code and install runtime requirements:\n```bash\ngit clone --recursive https://github.com/alicevision/Meshroom.git\ncd meshroom\n```\n\n### Install minimal dependencies\n\nTo use Meshroom nodal system without any visualization option, you can rely on a minimal set of dependencies.\n\n\n#### Python environment\n\n* Windows: Python 3 (>=3.9)\n* Linux: Python 3 (>=3.9)\n\nTo install all the requirements for runtime, development and packaging, simply run:\n```bash\npip install -r requirements.txt -r dev_requirements.txt\n```\n> [!NOTE]\n> `dev_requirements` is only related to testing and packaging. It is not mandatory to run Meshroom.\n\n> [!NOTE]\n> It is recommended to use a [virtual Python environment](https://docs.python.org/3.9/library/venv.html), like `python -m venv meshroom_venv`.\n\n\n#### Qt/PySide\n\n* PySide >= 6.7\n\n> [!WARNING]\n> 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`.\n> This is caused by Qt63DQuickScene3D.dll which seems to be missing from the pip distribution, but can be retrieved from a standard Qt installation. \n> On recent Linux systems such as Ubuntu 25, this can be resolved by installing `libqt63dquickscene3d6` using the package manager.\n> Alternatively:\n> - 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`.\n> - 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`.\n\n\n### Install dependencies\n\nYou 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.\n\n#### AliceVision\n\n[AliceVision](https://github.com/alicevision/AliceVision)'s binaries must be in the path while running Meshroom.\n\nThe 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).\n\nAlternatively, you can build AliceVision manually from the source code by following this [guide](https://github.com/alicevision/AliceVision/blob/develop/INSTALL.md).\n\nThen add the `bin` and `lib` folders into your `PATH` (and `LD_LIBRARY_PATH` on Linux/macOS) environment variables.\n\nThe following environment variable must always be set with the location of AliceVision's install directory:\n```\nALICEVISION_ROOT=/path/to/AliceVision/install/directory\n```\n\nAliceVision provides nodes and templates for Meshroom, which need to be declared to Meshroom with the following environment variables:\n```\nMESHROOM_NODES_PATH={ALICEVISION_ROOT}/share/meshroom\nMESHROOM_PIPELINE_TEMPLATES_PATH={ALICEVISION_ROOT}/share/meshroom\n```\n\nMeshroom 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.\nIf these variables are not set, Meshroom will by default look for them in `{ALICEVISION_ROOT}/share/aliceVision`.\n\n> [!NOTE]\n> You may need to checkout the corresponding Meshroom version/tag to avoid versions incompatibilities.\n\n\n#### QtAliceVision\n\n[QtAliceVision](https://github.com/alicevision/QtAliceVision), an additional Qt plugin, can be built to extend Meshroom UI features.\n\nNote that it is optional but highly recommended.\n\nThis plugin uses AliceVision to load and visualize intermediate reconstruction files and OpenImageIO as backend to read images (including RAW/EXR).\nIt also adds support for Alembic file loading in Meshroom's 3D viewport, which allows to visualize sparse reconstruction results (point clouds and cameras).\n\n```\nQML2_IMPORT_PATH=/path/to/QtAliceVision/install/qml\nQT_PLUGIN_PATH=/path/to/QtAliceVision/install\n```\n\n\n### Install plugins\n\n#### mrSegmentation plugin\n\nSome templates provided by AliceVision contain nodes that are not packaged with AliceVision.\nThese nodes are part of the mrSegmentation plugin, which can be found [here](https://github.com/MeshroomHub/mrSegmentation).\n\nTo build and install mrSegmentation, follow this [guide](https://github.com/MeshroomHub/mrSegmentation/blob/main/INSTALL.md).\n\nFor mrSegmentation nodes to be correctly detected by Meshroom, the following environment variable should be set:\n```\nMESHROOM_PLUGINS_PATH=/path/to/mrSegmentation\n```\n\n#### MeshroomHub\n\nYou can find many experimental Machine Learning plugins on [MeshroomHub](https://github.com/meshroomHub).\n\n\n### Start Meshroom\n\n - __Launch the User Interface__\n\n```bash\n# Windows\nset PYTHONPATH=%CD% && python meshroom/ui\n# Linux/macOS\nPYTHONPATH=$PWD python meshroom/ui\n```\n\nOn 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:\n`LD_LIBRARY_PATH=/usr/lib/nvidia-340 PYTHONPATH=$PWD python meshroom/ui`\nYou may need to adjust the folder `/usr/lib/nvidia-340` with the correct driver version.\n\n - __Launch a 3D reconstruction in command line__\n\n```bash\n# Windows: set PYTHONPATH=%CD% &&\n# Linux/macOS: PYTHONPATH=$PWD\npython bin/meshroom_batch --input INPUT_IMAGES_FOLDER --output OUTPUT_FOLDER\n```\n\n## Adding custom nodes, templates and plugins\n\nIn addition to the nodes and templates provided by Meshroom and AliceVision, custom ones can be created, loaded by, and used in Meshroom.\n\n### Custom nodes\n\nNodes need to be provided to Meshroom as Python modules, using the `MESHROOM_NODES_PATH` environment variable.\n\nFor example, to add a set of three custom nodes (`CustomNodeA`, `CustomNodeB` and `CustomNodeC`) to Meshroom, a Python\nmodule containing these nodes must be created:\n```\n├── folderA\n│   ├── customNodes\n│   │   ├── __init__.py\n│   │   ├── CustomNodeA.py\n│   │   ├── CustomNodeB.py\n│   │   └── CustomNodeC.py\n├── folderB\n```\n\nIts containing folder must then be added to `MESHROOM_NODES_PATH`:\n- On Windows:\n  ```\n  set MESHROOM_NODES_PATH=/path/to/folderA;%MESHROOM_NODES_PATH%\n  ```\n- On Linux:\n  ```\n  export MESHROOM_NODES_PATH=/path/to/folderA:$MESHROOM_NODES_PATH\n  ```\n\n> [!NOTE]\n> A valid Meshroom node is a Python file that contains a class inheriting `meshroom.core.desc.BaseNode`.\n> Before loading a node, Meshroom checks whether its description (the content of its class) is valid.\n> If it is not, the node is rejected with an error log describing which part is invalid.\n\n### Custom templates\n\nThe list of pipelines can also be enriched with custom templates, that are declared to Meshroom with the environment\nvariable `MESHROOM_PIPELINE_TEMPLATES_PATH`.\n\nFor example, if a couple of custom templates are saved in a folder \"customTemplates\", the variable should be set as follows:\n- On Windows:\n  ```\n  set MESHROOM_PIPELINE_TEMPLATES_PATH=/path/to/customTemplate;%MESHROOM_PIPELINE_TEMPLATES_PATH%\n  ```\n- On Linux:\n  ```\n  export MESHROOM_PIPELINE_TEMPLATES_PATH=/path/to/customTemplates:$MESHROOM_PIPELINE_TEMPLATES_PATH\n  ```\n\n> [!TIP]\n> A template can be a Meshroom graph of any type, but it is generally expected to be a graph saved in \"minimal mode\".\n> In \"minimal mode\", the .mg file only contains, for each node of the graph, the attributes that have non-default values.\n> To save a graph in \"minimal mode\", use the `File > Advanced > Save As Template` menu.\n\n### Custom plugins\n\nTo add and use custom plugins with Meshroom, follow [**INSTALL_PLUGINS.md**](INSTALL_PLUGINS.md).\n"
  },
  {
    "path": "INSTALL_PLUGINS.md",
    "content": "# Meshroom plugins installation\n\nPlugins 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.\n\n## Required Structure\n\n- **Meshroom folder**: All plugin nodes and templates must be placed within a `./meshroom/` directory\n- **Configuration file (optional)**: `./meshroom/config.json` file allows to define custom environment variables for the plugin  \n- **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.\n\n## Example Structure\n\nFor a plugin named \"customPlugin\", Meshroom expects this layout:\n```\n├── customPlugin/                # Plugin root folder\n│   ├── meshroom/                # Meshroom nodes and pipelines\n│   │   ├── customNodes1/        # Set of nodes\n│   │   │   ├── __init__.py      # Required to be a python module\n│   │   │   ├── NodeA.py\n│   │   │   ├── NodeB.py\n│   │   ├── customNodes2/        # Another set of nodes if needed\n│   │   │   ├── __init__.py\n│   │   │   ├── NodeC.py\n│   │   │   ├── NodeD.py\n│   │   ├── customTemplate1.mg   # Ready-to-use pipeline templates\n│   │   ├── customTemplate2.mg\n│   │   ├── config.json          # Optional plugin configuration file\n│   ├── venv/                    # Optional virtual environment with installed dependencies\n│   └── ...                      # Custom code (any structure)\n```\n\n## Loading the Plugin\n\nThe \"customPlugin\" will be loaded automatically when Meshroom starts by setting the `MESHROOM_PLUGINS_PATH` environment variable:\n- On Windows:\n  ```\n  set MESHROOM_PLUGINS_PATH=/path/to/customPlugin;%MESHROOM_PLUGINS_PATH%\n  ```\n- On Linux:\n  ```\n  export MESHROOM_PLUGINS_PATH=/path/to/customPlugin:$MESHROOM_PLUGINS_PATH\n  ```\n"
  },
  {
    "path": "LICENSE-MPL2.md",
    "content": "Mozilla Public License Version 2.0\n==================================\n\n1. Definitions\n--------------\n\n1.1. \"Contributor\"\n    means each individual or legal entity that creates, contributes to\n    the creation of, or owns Covered Software.\n\n1.2. \"Contributor Version\"\n    means the combination of the Contributions of others (if any) used\n    by a Contributor and that particular Contributor's Contribution.\n\n1.3. \"Contribution\"\n    means Covered Software of a particular Contributor.\n\n1.4. \"Covered Software\"\n    means Source Code Form to which the initial Contributor has attached\n    the notice in Exhibit A, the Executable Form of such Source Code\n    Form, and Modifications of such Source Code Form, in each case\n    including portions thereof.\n\n1.5. \"Incompatible With Secondary Licenses\"\n    means\n\n    (a) that the initial Contributor has attached the notice described\n        in Exhibit B to the Covered Software; or\n\n    (b) that the Covered Software was made available under the terms of\n        version 1.1 or earlier of the License, but not also under the\n        terms of a Secondary License.\n\n1.6. \"Executable Form\"\n    means any form of the work other than Source Code Form.\n\n1.7. \"Larger Work\"\n    means a work that combines Covered Software with other material, in \n    a separate file or files, that is not Covered Software.\n\n1.8. \"License\"\n    means this document.\n\n1.9. \"Licensable\"\n    means having the right to grant, to the maximum extent possible,\n    whether at the time of the initial grant or subsequently, any and\n    all of the rights conveyed by this License.\n\n1.10. \"Modifications\"\n    means any of the following:\n\n    (a) any file in Source Code Form that results from an addition to,\n        deletion from, or modification of the contents of Covered\n        Software; or\n\n    (b) any new file in Source Code Form that contains any Covered\n        Software.\n\n1.11. \"Patent Claims\" of a Contributor\n    means any patent claim(s), including without limitation, method,\n    process, and apparatus claims, in any patent Licensable by such\n    Contributor that would be infringed, but for the grant of the\n    License, by the making, using, selling, offering for sale, having\n    made, import, or transfer of either its Contributions or its\n    Contributor Version.\n\n1.12. \"Secondary License\"\n    means either the GNU General Public License, Version 2.0, the GNU\n    Lesser General Public License, Version 2.1, the GNU Affero General\n    Public License, Version 3.0, or any later versions of those\n    licenses.\n\n1.13. \"Source Code Form\"\n    means the form of the work preferred for making modifications.\n\n1.14. \"You\" (or \"Your\")\n    means an individual or a legal entity exercising rights under this\n    License. For legal entities, \"You\" includes any entity that\n    controls, is controlled by, or is under common control with You. For\n    purposes of this definition, \"control\" means (a) the power, direct\n    or indirect, to cause the direction or management of such entity,\n    whether by contract or otherwise, or (b) ownership of more than\n    fifty percent (50%) of the outstanding shares or beneficial\n    ownership of such entity.\n\n2. License Grants and Conditions\n--------------------------------\n\n2.1. Grants\n\nEach Contributor hereby grants You a world-wide, royalty-free,\nnon-exclusive license:\n\n(a) under intellectual property rights (other than patent or trademark)\n    Licensable by such Contributor to use, reproduce, make available,\n    modify, display, perform, distribute, and otherwise exploit its\n    Contributions, either on an unmodified basis, with Modifications, or\n    as part of a Larger Work; and\n\n(b) under Patent Claims of such Contributor to make, use, sell, offer\n    for sale, have made, import, and otherwise transfer either its\n    Contributions or its Contributor Version.\n\n2.2. Effective Date\n\nThe licenses granted in Section 2.1 with respect to any Contribution\nbecome effective for each Contribution on the date the Contributor first\ndistributes such Contribution.\n\n2.3. Limitations on Grant Scope\n\nThe licenses granted in this Section 2 are the only rights granted under\nthis License. No additional rights or licenses will be implied from the\ndistribution or licensing of Covered Software under this License.\nNotwithstanding Section 2.1(b) above, no patent license is granted by a\nContributor:\n\n(a) for any code that a Contributor has removed from Covered Software;\n    or\n\n(b) for infringements caused by: (i) Your and any other third party's\n    modifications of Covered Software, or (ii) the combination of its\n    Contributions with other software (except as part of its Contributor\n    Version); or\n\n(c) under Patent Claims infringed by Covered Software in the absence of\n    its Contributions.\n\nThis License does not grant any rights in the trademarks, service marks,\nor logos of any Contributor (except as may be necessary to comply with\nthe notice requirements in Section 3.4).\n\n2.4. Subsequent Licenses\n\nNo Contributor makes additional grants as a result of Your choice to\ndistribute the Covered Software under a subsequent version of this\nLicense (see Section 10.2) or under the terms of a Secondary License (if\npermitted under the terms of Section 3.3).\n\n2.5. Representation\n\nEach Contributor represents that the Contributor believes its\nContributions are its original creation(s) or it has sufficient rights\nto grant the rights to its Contributions conveyed by this License.\n\n2.6. Fair Use\n\nThis License is not intended to limit any rights You have under\napplicable copyright doctrines of fair use, fair dealing, or other\nequivalents.\n\n2.7. Conditions\n\nSections 3.1, 3.2, 3.3, and 3.4 are conditions of the licenses granted\nin Section 2.1.\n\n3. Responsibilities\n-------------------\n\n3.1. Distribution of Source Form\n\nAll distribution of Covered Software in Source Code Form, including any\nModifications that You create or to which You contribute, must be under\nthe terms of this License. You must inform recipients that the Source\nCode Form of the Covered Software is governed by the terms of this\nLicense, and how they can obtain a copy of this License. You may not\nattempt to alter or restrict the recipients' rights in the Source Code\nForm.\n\n3.2. Distribution of Executable Form\n\nIf You distribute Covered Software in Executable Form then:\n\n(a) such Covered Software must also be made available in Source Code\n    Form, as described in Section 3.1, and You must inform recipients of\n    the Executable Form how they can obtain a copy of such Source Code\n    Form by reasonable means in a timely manner, at a charge no more\n    than the cost of distribution to the recipient; and\n\n(b) You may distribute such Executable Form under the terms of this\n    License, or sublicense it under different terms, provided that the\n    license for the Executable Form does not attempt to limit or alter\n    the recipients' rights in the Source Code Form under this License.\n\n3.3. Distribution of a Larger Work\n\nYou may create and distribute a Larger Work under terms of Your choice,\nprovided that You also comply with the requirements of this License for\nthe Covered Software. If the Larger Work is a combination of Covered\nSoftware with a work governed by one or more Secondary Licenses, and the\nCovered Software is not Incompatible With Secondary Licenses, this\nLicense permits You to additionally distribute such Covered Software\nunder the terms of such Secondary License(s), so that the recipient of\nthe Larger Work may, at their option, further distribute the Covered\nSoftware under the terms of either this License or such Secondary\nLicense(s).\n\n3.4. Notices\n\nYou may not remove or alter the substance of any license notices\n(including copyright notices, patent notices, disclaimers of warranty,\nor limitations of liability) contained within the Source Code Form of\nthe Covered Software, except that You may alter any license notices to\nthe extent required to remedy known factual inaccuracies.\n\n3.5. Application of Additional Terms\n\nYou may choose to offer, and to charge a fee for, warranty, support,\nindemnity or liability obligations to one or more recipients of Covered\nSoftware. However, You may do so only on Your own behalf, and not on\nbehalf of any Contributor. You must make it absolutely clear that any\nsuch warranty, support, indemnity, or liability obligation is offered by\nYou alone, and You hereby agree to indemnify every Contributor for any\nliability incurred by such Contributor as a result of warranty, support,\nindemnity or liability terms You offer. You may include additional\ndisclaimers of warranty and limitations of liability specific to any\njurisdiction.\n\n4. Inability to Comply Due to Statute or Regulation\n---------------------------------------------------\n\nIf it is impossible for You to comply with any of the terms of this\nLicense with respect to some or all of the Covered Software due to\nstatute, judicial order, or regulation then You must: (a) comply with\nthe terms of this License to the maximum extent possible; and (b)\ndescribe the limitations and the code they affect. Such description must\nbe placed in a text file included with all distributions of the Covered\nSoftware under this License. Except to the extent prohibited by statute\nor regulation, such description must be sufficiently detailed for a\nrecipient of ordinary skill to be able to understand it.\n\n5. Termination\n--------------\n\n5.1. The rights granted under this License will terminate automatically\nif You fail to comply with any of its terms. However, if You become\ncompliant, then the rights granted under this License from a particular\nContributor are reinstated (a) provisionally, unless and until such\nContributor explicitly and finally terminates Your grants, and (b) on an\nongoing basis, if such Contributor fails to notify You of the\nnon-compliance by some reasonable means prior to 60 days after You have\ncome back into compliance. Moreover, Your grants from a particular\nContributor are reinstated on an ongoing basis if such Contributor\nnotifies You of the non-compliance by some reasonable means, this is the\nfirst time You have received notice of non-compliance with this License\nfrom such Contributor, and You become compliant prior to 30 days after\nYour receipt of the notice.\n\n5.2. If You initiate litigation against any entity by asserting a patent\ninfringement claim (excluding declaratory judgment actions,\ncounter-claims, and cross-claims) alleging that a Contributor Version\ndirectly or indirectly infringes any patent, then the rights granted to\nYou by any and all Contributors for the Covered Software under Section\n2.1 of this License shall terminate.\n\n5.3. In the event of termination under Sections 5.1 or 5.2 above, all\nend user license agreements (excluding distributors and resellers) which\nhave been validly granted by You or Your distributors under this License\nprior to termination shall survive termination.\n\n************************************************************************\n*                                                                      *\n*  6. Disclaimer of Warranty                                           *\n*  -------------------------                                           *\n*                                                                      *\n*  Covered Software is provided under this License on an \"as is\"       *\n*  basis, without warranty of any kind, either expressed, implied, or  *\n*  statutory, including, without limitation, warranties that the       *\n*  Covered Software is free of defects, merchantable, fit for a        *\n*  particular purpose or non-infringing. The entire risk as to the     *\n*  quality and performance of the Covered Software is with You.        *\n*  Should any Covered Software prove defective in any respect, You     *\n*  (not any Contributor) assume the cost of any necessary servicing,   *\n*  repair, or correction. This disclaimer of warranty constitutes an   *\n*  essential part of this License. No use of any Covered Software is   *\n*  authorized under this License except under this disclaimer.         *\n*                                                                      *\n************************************************************************\n\n************************************************************************\n*                                                                      *\n*  7. Limitation of Liability                                          *\n*  --------------------------                                          *\n*                                                                      *\n*  Under no circumstances and under no legal theory, whether tort      *\n*  (including negligence), contract, or otherwise, shall any           *\n*  Contributor, or anyone who distributes Covered Software as          *\n*  permitted above, be liable to You for any direct, indirect,         *\n*  special, incidental, or consequential damages of any character      *\n*  including, without limitation, damages for lost profits, loss of    *\n*  goodwill, work stoppage, computer failure or malfunction, or any    *\n*  and all other commercial damages or losses, even if such party      *\n*  shall have been informed of the possibility of such damages. This   *\n*  limitation of liability shall not apply to liability for death or   *\n*  personal injury resulting from such party's negligence to the       *\n*  extent applicable law prohibits such limitation. Some               *\n*  jurisdictions do not allow the exclusion or limitation of           *\n*  incidental or consequential damages, so this exclusion and          *\n*  limitation may not apply to You.                                    *\n*                                                                      *\n************************************************************************\n\n8. Litigation\n-------------\n\nAny litigation relating to this License may be brought only in the\ncourts of a jurisdiction where the defendant maintains its principal\nplace of business and such litigation shall be governed by laws of that\njurisdiction, without reference to its conflict-of-law provisions.\nNothing in this Section shall prevent a party's ability to bring\ncross-claims or counter-claims.\n\n9. Miscellaneous\n----------------\n\nThis License represents the complete agreement concerning the subject\nmatter hereof. If any provision of this License is held to be\nunenforceable, such provision shall be reformed only to the extent\nnecessary to make it enforceable. Any law or regulation which provides\nthat the language of a contract shall be construed against the drafter\nshall not be used to construe this License against a Contributor.\n\n10. Versions of the License\n---------------------------\n\n10.1. New Versions\n\nMozilla Foundation is the license steward. Except as provided in Section\n10.3, no one other than the license steward has the right to modify or\npublish new versions of this License. Each version will be given a\ndistinguishing version number.\n\n10.2. Effect of New Versions\n\nYou may distribute the Covered Software under the terms of the version\nof the License under which You originally received the Covered Software,\nor under the terms of any subsequent version published by the license\nsteward.\n\n10.3. Modified Versions\n\nIf you create software not governed by this License, and you want to\ncreate a new license for such software, you may create and use a\nmodified version of this License if you rename the license and remove\nany references to the name of the license steward (except to note that\nsuch modified license differs from this License).\n\n10.4. Distributing Source Code Form that is Incompatible With Secondary\nLicenses\n\nIf You choose to distribute Source Code Form that is Incompatible With\nSecondary Licenses under the terms of this version of the License, the\nnotice described in Exhibit B of this License must be attached.\n\nExhibit A - Source Code Form License Notice\n-------------------------------------------\n\n  This Source Code Form is subject to the terms of the Mozilla Public\n  License, v. 2.0. If a copy of the MPL was not distributed with this\n  file, You can obtain one at http://mozilla.org/MPL/2.0/.\n\nIf it is not possible or desirable to put the notice in a particular\nfile, then You may include the notice in a location (such as a LICENSE\nfile in a relevant directory) where a recipient would be likely to look\nfor such a notice.\n\nYou may add additional accurate notices of copyright ownership.\n\nExhibit B - \"Incompatible With Secondary Licenses\" Notice\n---------------------------------------------------------\n\n  This Source Code Form is \"Incompatible With Secondary Licenses\", as\n  defined by the Mozilla Public License, v. 2.0.\n"
  },
  {
    "path": "NODE_DEVELOPMENT.md",
    "content": "# Meshroom Node Development\n\n\n## Node Creation\n\nThis guide shows how to implement three common Meshroom node types: Python-based `Node`, external-executable `CommandLineNode`, and non-computational `InputNode`.\n\n### 1. Node (Pure Python)\n\nUse `desc.Node` when your logic runs in Python.\nImplement `process(self, node)` to produce outputs.\n\n#### Example: Generate a file\n\n```python\nfrom meshroom.core import desc\n\nclass GenerateFile(desc.Node):\n    category = \"Custom\"\n    inputs = [\n        desc.File(name=\"input\", label=\"Input\", description=\"\", value=\"\"),\n        desc.IntParam(name=\"count\", label=\"Count\", description=\"\", value=1),\n    ]\n    outputs = [\n        desc.File(name=\"output\", label=\"Output\", description=\"\", value=\"{nodeCacheFolder}/out.txt\"),\n    ]\n\n    def process(self, node):\n        # Implement your computation logic here\n        with open(node.output.value, \"w\") as f:\n            f.write(f\"Processed {node.input.value} ({node.count.value})\\n\")\n```\nIn 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.\n\n#### Example: Compute values\n\n```python\nclass AddInt(desc.Node):\n    category = \"Custom\"\n    inputs = [\n        desc.IntParam(name=\"a\", label=\"Count\", description=\"\", value=1),\n        desc.IntParam(name=\"b\", label=\"Count\", description=\"\", value=2),\n    ]\n    outputs = [\n        # Dynamic output value\n        desc.IntParam(name=\"outputInt\", label=\"Count\", description=\"\", value=None),\n    ]\n\n    def process(self, node):\n        # Implement your logic here; set output attributes.\n        node.outputInt.value = node.a.value + node.b.value\n```\nIn this example, the output param value will ve valid in Meshroom only at the end of the node computation.\n\n\n### 2. CommandLineNode (external executable)\n\nUse `desc.CommandLineNode` to wrap an external binary. Define a `commandLine` template with `{variable}` placeholders. Meshroom expands it via `buildCommandLine(chunk)` and executes the result.\n\n#### Example\n\n```python\nfrom meshroom.core import desc\n\nclass MyCmdNode(desc.CommandLineNode):\n    commandLine = \"mytool --input {inputValue} --output {outputValue}\"\n\n    inputs = [\n        desc.File(name=\"input\", label=\"Input\", description=\"\", value=\"\"),\n    ]\n    outputs = [\n        desc.File(name=\"output\", label=\"Output\", description=\"\", value=\"{nodeCacheFolder}/out.txt\"),\n    ]\n```\n\n### 3. InputNode (non-computational placeholder)\n\nUse `desc.InputNode` for nodes that only hold data and do not run computation.\n\n#### Example: Input Node\n\n```python\nfrom meshroom.core import desc\n\nclass MyInputNode(desc.InputNode):\n    category = \"Custom\"\n    inputs = [\n        desc.File(name=\"file\", label=\"File\", description=\"\", value=\"\"),\n    ]\n```\n\n#### Example: Input Node with Initialization\n\nThe InitNodes could be combined with `desc.InitNode` to implement `initialize` for command line batching or initialization from drag&drop.\n\n```python\nfrom meshroom.core import desc\n\nclass MyInputNode(desc.InputNode, desc.InitNode):\n    category = \"Custom\"\n    inputs = [\n        desc.File(name=\"file\", label=\"File\", description=\"\", value=\"\"),\n    ]\n\n    def initialize(self, node, inputs, recursiveInputs):\n        # Populate attributes from command-line inputs.\n        if inputs:\n            node.file.value = inputs[0]\n```\n\n\n## Attribute Types Available in Meshroom Nodes\n\nMeshroom 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.\n\n### Basic Parameters\n\n| Type | Description | Common Options |\n|------|-------------|----------------|\n| `BoolParam` | Boolean toggle. | `value` (bool) |\n| `IntParam` | Integer with optional range. | `range=(min, max, step)` |\n| `FloatParam` | Floating-point with optional range. | `range=(min, max, step)` |\n| `StringParam` | Free-form string. | `value` (str) |\n| `File` | File or directory path. | `value` (str) |\n| `ChoiceParam` | Single or multiple selection from a list. | `values=[...]`, `exclusive` |\n| `ColorParam` | RGBA color. | `value` (list/tuple) |\n| `PushButtonParam` | Action button in UI; no stored value. | N/A |\n\n### Compound Containers\n\n| Type | Description | Key Args |\n|------|-------------|----------|\n| `ListAttribute` | Homogeneous list of elements defined by `elementDesc`. | `elementDesc`, `joinChar` |\n| `GroupAttribute` | Fixed collection of heterogeneous child attributes (`items`). | `items`, `joinChar` |\n\nBoth inherit from `Attribute` and support nesting (lists of groups, groups with lists).\n\n#### Example: Parameter Types\n\n```python\nfrom meshroom.core import desc\n\nclass ParameterTypesSample(desc.Node):\n    category = \"Custom\"\n    inputs = [\n        desc.BoolParam(name=\"boolParam\", label=\"Boolean\", description=\"\", value=False),\n        desc.IntParam(name=\"intParam\", label=\"Integer\", description=\"\", value=10, range=(0, 100, 1)),\n        desc.FloatParam(name=\"floatParam\", label=\"Float\", description=\"\", value=3.14, range=(0.0, 10.0, 0.1)),\n        desc.StringParam(name=\"stringParam\", label=\"String\", description=\"\", value=\"default\"),\n        desc.File(name=\"fileParam\", label=\"File\", description=\"\", value=\"\"),\n        desc.ChoiceParam(name=\"choiceParam\", label=\"Choice\", description=\"\", value=\"opt1\", values=[\"opt1\", \"opt2\", \"opt3\"], exclusive=True),\n        desc.ColorParam(name=\"colorParam\", label=\"Color\", description=\"\", value=[1.0, 0.0, 0.0, 1.0]),\n        desc.PushButtonParam(name=\"buttonParam\", label=\"Button\", description=\"\"),\n        desc.ListAttribute(\n            name=\"fileList\",\n            label=\"File List\",\n            description=\"\",\n            elementDesc=desc.File(name=\"file\", label=\"File\", description=\"\", value=\"\"),\n            joinChar=\" \"\n        ),\n        desc.GroupAttribute(\n            name=\"inputGroup\",\n            label=\"Input Group\",\n            description=\"Group with bool, int, string and file\",\n            items=[\n                desc.BoolParam(name=\"groupBool\", label=\"Boolean\", description=\"\", value=True),\n                desc.IntParam(name=\"groupInt\", label=\"Integer\", description=\"\", value=42, range=(0, 100, 1)),\n                desc.StringParam(name=\"groupString\", label=\"String\", description=\"\", value=\"groupValue\"),\n                desc.File(name=\"groupFile\", label=\"File\", description=\"\", value=\"\")\n            ]\n        )\n    ]\n    outputs = [\n        desc.File(name=\"outputFile\", label=\"Output File\", description=\"\", value=\"{nodeCacheFolder}/output.txt\")\n    ]\n\n    def process(self, node):\n        with open(node.outputFile.value, \"w\") as f:\n            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\")\n```\n\n### Geometry Helpers\n\nConvenient groups for 2D geometry, built from `GroupAttribute` and `FloatParam`:\n\n| Type | Fields | Example |\n|------|--------|---------|\n| `Size2d` | `width`, `height` (float) | `Size2d(name=\"sz\", ..., width=1920, height=1080)` |\n| `Vec2d` | `x`, `y` (float) | `Vec2d(name=\"vec\", ..., x=0.0, y=1.0)` |\n\n\n### Attribute Properties\n\n- **Name**: Used to access attributes from script.\n- **Label**: Label used for the display in the Node Editor.\n- **Description**: Tooltip used in the Node Editor.\n- **Range constraints**: `IntParam` and `FloatParam` accept `range=(min, max, step)` to bound values.\n- **Enabled**: Parameters can be enabled or disabled dynamically (using a lamda).\n- **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.\n- **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.\n- **Dynamic outputs**: Set `value=None` in an output attribute to mark it as dynamically computed.\n- **Keyable attributes**: Enable per-key values (e.g., per-view) with `keyable=True` and `keyType`. Supported on basic params and shapes.\n- **JoinChar**: Controls string serialization for `ListAttribute` and `GroupAttribute` when used in command lines.\n\n\n### Advanced: Shape Parameters\n\nUsed for UI overlays/annotations; they support `keyable` per-view values:\n\n| Type | Description | Example |\n|------|-------------|---------|\n| `Point2d` | 2D point (`x`, `y`). | `Point2d(name=\"pt\", ...)` |\n| `Line2d` | 2D line defined by two points. | `Line2d(name=\"ln\", ...)` |\n| `Rectangle` | Axis-aligned rectangle. | `Rectangle(name=\"rect\", ...)` |\n| `Circle` | Circle with center and radius. | `Circle(name=\"c\", ...)` |\n| `ShapeList` | List of a single shape type (`shape`). | `ShapeList(name=\"pts\", shape=Point2d(...))` |\n\n\n## Node Descriptor Properties\n\n| Property | Type | Description | Default |\n|----------|------|-------------|---------|\n| Class documentation | str | Detailed description of the node's purpose | \"\" |\n| `category` | str | Organizational category in the node library | \"Other\" |\n| `cpu` | Level or callable | CPU resource requirement level | Level.NORMAL |\n| `ram` | Level or callable | Memory resource requirement level | Level.NORMAL |\n| `gpu` | Level or callable | GPU resource requirement level | Level.NONE |\n| `size` | Size object | Parallelization size configuration | StaticNodeSize(1) |\n| `parallelization` | Parallelization | Chunk division settings | None |\n\n### Example: Basic Node with Properties\n\n```python\nclass SampleNode(desc.Node):\n    \"\"\"This is the Node documentation that will be available in the Node Editor.\"\"\"\n\n    category = \"Custom Node Category\"  # Used in the UI to group nodes in the menu\n    size = desc.DynamicNodeSize(\"inputFiles\")  # Size used to define the number of chunks for parallelization\n\n    # Resource levels (`cpu`, `gpu`, `ram`) are used for farm scheduling on suitable hardware\n    cpu = Level.NORMAL  # Need standard amount of CPU\n    ram = Level.HIGH  # Requires large amount of RAM\n    gpu = Level.NONE  # Do not need GPU\n```\n\nResource levels can also be set as callables receiving a node instance, allowing them to be\ndetermined dynamically based on the node's input parameters:\n\n```python\nclass SampleNode(desc.Node):\n    # Dynamically require a GPU based on an input parameter\n    gpu = lambda node: desc.Level.INTENSIVE if node.attribute(\"useGpu\").value else desc.Level.NONE\n```\n\nThe resolved value for a node instance is accessible via the `cpu`, `gpu`, and `ram` properties\non the node object (e.g. `node.cpu`, `node.gpu`, `node.ram`).\n\n\n## Parallelizing a Node\n\nMeshroom 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.\n\n### Configuration\n\n#### Size Strategies\n- **StaticNodeSize**: Fixed number of tasks\n- **DynamicNodeSize**: Size based on an input attribute (list length or linked node size)\n- **MultiDynamicNodeSize**: Sum of sizes from multiple input attributes\n- **callable**: A callable (e.g. a lambda) receiving the node instance: `lambda node: node.sizeInput.value`\n\n#### Parallelization Settings\nSet `parallelization` to control chunk division:\n- `blockSize`: Items per chunk\n- `staticNbBlocks`: Fixed number of chunks (alternative to blockSize)\n\n### Implementation Examples\n\n#### CommandLineNode with Static Parallelization\n```python\nclass MyParallelCmd(desc.CommandLineNode):\n    commandLine = \"mytool --input {inputValue} --output {outputValue}\"\n    commandLineRange = \"--range {rangeStart} {rangeEnd}\"  # Specific way to precise the range to compute on the command line\n    \n    size = desc.StaticNodeSize(100)  # 100 items total\n    parallelization = desc.Parallelization(blockSize=10)  # 10 chunks of 10 items\n```\n\n#### Node with Dynamic Size\n```python\nclass MyParallelNode(desc.Node):\n    size = desc.DynamicNodeSize(\"inputList\")  # Size matches list length\n    parallelization = desc.Parallelization(blockSize=3)  # Create a chunk every 3 elements in the list\n    \n    def processChunk(self, chunk):\n        # Process chunk.range.iteration\n        pass\n```\n\n### Range and Chunk Behavior\n\nEach chunk receives a `Range` object with:\n- `iteration`: Chunk index\n- `start`/`end`: Item indices for this chunk\n- `blockSize`: Items per chunk\n- `nbBlocks`: Total chunks\n\nFor `CommandLineNode`, range placeholders are automatically injected into `commandLineRange` when `node.isParallelized` and `node.size > 1`.\n\n\n## Installation\n\nSee [INSTALL_PLUGINS.md](./INSTALL_PLUGINS.md)\n\n"
  },
  {
    "path": "README.md",
    "content": "# ![Meshroom - 3D Reconstruction Software](/docs/logo/banner-meshroom.png)\n\nMeshroom is an open-source, node-based visual programming framework—a flexible toolbox for creating, managing, and executing complex data processing pipelines.\n\nMeshroom 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.\n\nMeshroom supports both local and distributed execution, enabling efficient parallel processing on render farms.\nIt also includes interactive widgets for visualizing images and 3D data. Official releases come with built-in plugins for computer vision and machine learning tasks.\n\n[![Ask DeepWiki](https://deepwiki.com/badge.svg)](https://deepwiki.com/alicevision/Meshroom)\n[![CII Best Practices](https://bestpractices.coreinfrastructure.org/projects/2997/badge)](https://bestpractices.coreinfrastructure.org/projects/2997)\n[![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)\n\n\n# Get the project\n\nYou can [download pre-compiled binaries for the latest release](https://github.com/alicevision/meshroom/releases).\n\nIf you want to build it yourself, see [**INSTALL.md**](INSTALL.md) to setup the project and pre-requisites.\n\nTo use Meshroom with custom plugins, see [**INSTALL_PLUGINS.md**](INSTALL_PLUGINS.md).\n\n\n# Concepts\n\n- **Graph**: A collection of interconnected nodes that defines the sequence of operations to represent your complete data processing workflow.\n- **Nodes**: The fundamental building blocks, each performing a specific task. Nodes are connected through edges that represent the flow of data between them.\n- **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.\n- **Templates**: Ready-to-use pipeline configurations provided by plugins. You can customize existing templates or create and save your own.\n- **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.\n- **Custom Plugins**: Extend Meshroom's capabilities by creating your own nodes in Python or by integrating external command-line tools.\n\n\n# User Interface\n\nThe Meshroom UI is divided into several key areas:\n - **Graph Editor**: The central area where nodes are placed and connected to form a processing pipeline.\n - **Node Editor**: It contains multiple tabs with:\n   - **Attributes**: Displays the attributes and parameters of the selected node.\n   - **Log**: Displays execution logs and error messages.\n   - **Statistics**: Displays resource consumption\n   - **Status**: Display some technical information on the node (workstation, start/end time, etc.)\n   - **Documentation**: Node Documentation.\n   - **Notes**: Change label or put some notes on the node to know why it’s used in this graph.\n - **2D & 3D Viewer**: Visualizes the output of certain nodes.\n - **Image Gallery**: Visualize the list of input files.\n\n\n# Manual and Tutorials\n - [Meshroom Manual](https://meshroom-manual.readthedocs.io)\n - [Meshroom FAQ](https://github.com/alicevision/meshroom/wiki)\n\n\n# Plugins bundled by default\n\n## AliceVision Plugin\n\n[AliceVision Website](http://alicevision.org)\n\n[AliceVision Repository](https://github.com/alicevision/AliceVision)\n\nAliceVision 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.\nThe AliceVision plugin offers comprehensive pipelines for:\n- **3D Reconstruction** from multi-view images ([pipeline overview](http://alicevision.github.io/#photogrammetry), [results on Sketchfab](http://sketchfab.com/AliceVision))\n- **Camera Tracking** for camera motion estimation\n- **HDR Fusion** from multi-bracketed photography\n- **Panorama Stitching** including fisheye support and motorized head systems\n- **Photometric Stereo** for geometric reconstruction from a single view with multiple lightings\n- **Multi-View Photometric Stereo** combining photogrammetry with photometric stereo\n\n\n## Segmentation Plugin\n\n[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.\n\n\n# Other plugins\n\nSee [MeshroomHub](https://github.com/meshroomHub) for more plugins.\n\n## DepthEstimation Plugin\n\n[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.\n\n\n## RoMa Plugin\n\n[MrRoma](https://github.com/meshroomHub/mrRoma): A set of nodes for RoMa (robust dense feature matching).\nThe 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.\n\n\n## GSplat Plugin\n\n[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.\n\n\n## Research Plugin\n\n[Meshroom Research](https://github.com/meshroomHub/MeshroomResearch)\nA 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.\n\n\n## MicMac Plugin\n\n[MeshroomMicMac](https://github.com/alicevision/MeshroomMicMac)\nAn 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.\n\n\n## Geolocation Plugin\n\n[MrGeolocation](https://github.com/meshroomHub/mrGeolocation)\nA 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.\n\n\n# License\n\nThe project is released under MPLv2, see [**COPYING.md**](COPYING.md).\n\n\n# Citation\n  ```\n  @inproceedings{alicevision2021,\n    title={{A}liceVision {M}eshroom: An open-source {3D} reconstruction pipeline},\n    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},\n    booktitle={Proceedings of the 12th ACM Multimedia Systems Conference - {MMSys '21}},\n    doi = {10.1145/3458305.3478443},\n    publisher = {ACM Press},\n    year = {2021}\n  }\n  ```\n\n\n# Contributing\nWe 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.\n\n\n# Contact\n\nUse 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)\n\nYou can also contact the core team privately on: [team@alicevision.org](mailto:team@alicevision.org).\n"
  },
  {
    "path": "RELEASING.md",
    "content": "\n### Versioning\n\nVersion = MAJOR (>=1 year), MINOR (>= 1 month), PATCH\n\nVersion Status = Develop / Release\n\n\n### Git\n\nBranches\n    develop: active development branch\n    master: latest release\n    vMAJOR.MINOR: release branch\n\nTags\n    vMAJOR.MINOR.PATCH: tag for each release\n\n\n### Release Process\n\n - Prepare the AliceVision release: https://github.com/alicevision/AliceVision\n - Update INSTALL.md and requirements.txt if needed\n - Source code\n   - Create branch from develop: \"rcMAJOR.MINOR\"\n   - Modify version in code, version status to RELEASE (meshroom/__init__.py)\n   - Update the version of all the templates so their version corresponds to the release\n   - Create Release note (using https://github.com/cbentejac/github-generate-release-note)\n     - ```\n\t   ./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\n\t   ```\n   - PR to develop: \"Release MAJOR.MINOR\"\n - Build\n   - Build docker & push to dockerhub\n   - Build windows\n - Git\n   - Merge \"rcMAJOR.MINOR\" into \"develop\"\n   - Push \"develop\" into \"master\"\n   - Create branch: vMAJOR.MINOR\n   - Create tag: vMAJOR.MINOR.PATCH on Meshroom, qtAliceVision\n   - Create branch from develop: \"startMAJOR.MINOR\"\n - Upload binaries on fosshub\n - Fill up Github release note\n - Prepare \"develop\" for new developments\n   - Upgrade MINOR and reset version status to Develop\n   - PR to develop: \"Start Development MAJOR.MINOR\"\n - Communication\n   - Email on mailing-list: alicevision@googlegroups.com\n   - Message on linkedin: https://www.linkedin.com/groups/13573776\n   - Message on twitter: https://twitter.com/alicevision_org\n\n### Upgrade a Release with a PATCH version\n\n - Source code\n   - Create branch from rcMAJOR.MINOR: \"rcMAJOR.MINOR.PATCH\"\n   - Cherry-pick specific commits or rebase required PR\n   - Modify version in code\n   - Update release note\n - Build step\n - Uploads\n - Github release note\n - Email on mailing-list\n\n"
  },
  {
    "path": "WINDOWS_EXE.md",
    "content": "# Meshroom executable generation on Windows\n\nThis describes how to generate Meshroom's executable on Windows. This does not include any plugin, only Meshroom itself.\n\n## Set helper environment variables\n\n```bash\nset SRC_ROOT=/path/to/Meshroom/repository\nset PYTHON=/path/to/Python/Python311/python.exe\nset RELEASE_VERSION=2026.x.x\nset MESHROOM_EXE_DIR=/path/to/Meshroom-%RELEASE_VERSION%\n```\n\n## Meshroom build\n\n### Prepare environment\n\n```bash\ncd %SRC_ROOT%\n%PYTHON% -m venv venv\ncall venv\\Scripts\\activate.bat\npip install -r requirements.txt -r dev_requirements.txt\n```\n\n### Executable generation\n\n```bash\npython setup.py install_exe -d %MESHROOM_EXE_DIR%\ndeactivate\n```\n\n> [!IMPORTANT]\n> 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:\n> `Cannot load /path/to/pip/install/PySide6/qml/QtQuick/Scene3D/qtquickscene3dplugin.dll: specified module cannot be found`.\n> 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.\n> It needs to be placed in `%MESHROOM_EXE_DIR%/lib/PySide6`.\n\n### Clean the packages\n\nGet rid of all the things that are unnecessary for Meshroom. This will lighten the final package.\n\n```bash\ncd %MESHROOM_EXE_DIR%/lib/PySide6\ndel /s /q Qt6Web*.dll Qt6Designer*.dll *.exe\nrmdir /s /q resources translations typesystems examples include\n```\n"
  },
  {
    "path": "bin/meshroom_batch",
    "content": "#!/usr/bin/env python\nimport argparse\nimport json\nimport logging\nimport os\nimport re\nimport sys\n\nimport meshroom.core.graph\nfrom meshroom.common import strtobool\nfrom meshroom import setupEnvironment, logStringToPython\n\ndef parseInitInputs(inputs: list[str]) -> dict[str, str]:\n    \"\"\"Utility method for parsing the input and inputRecursive arguments.\n    Args:\n        inputs: Command line values in format 'nodeName=value' or just 'value' to set it on all init nodes\n\n    Returns:\n        Dict mapping node names (or empty string if it applies to all) to their input values\n\n    Raises:\n        ValueError: If input format is invalid\n    \"\"\"\n    mapInputs = {}\n    for inp in inputs:\n        # Stop after the first occurrence\n        inputGroup = inp.split('=', 1)\n        nodeName = inputGroup[0] if len(inputGroup) == 2 else \"\"\n        nodeInputs = inputGroup[-1].split(',')\n        mapInputs[nodeName] = [os.path.abspath(path) for path in nodeInputs]\n    return mapInputs\n\n\nsetupEnvironment()\n\nmeshroom.core.initPipelines()\n\nparser = argparse.ArgumentParser(\n    prog='meshroom_batch',\n    description='Launch a Meshroom pipeline from command line.',\n    add_help=True,\n    formatter_class=argparse.RawTextHelpFormatter,\n    epilog='''\nExamples:\n  1. Process a pipeline in command line:\n     meshroom_batch -p cameraTracking -i /input/path -o /output/path -s /path/to/store/the/project.mg\n\n  2. Submit a pipeline on renderfarm:\n     meshroom_batch -p cameraTracking -i /input/path -o /output/path -s /path/to/store/the/project.mg --submit\n\n  See \"meshroom_compute -h\" to compute an existing project from command line.\n\nAdditional Resources:\n  Website:      https://alicevision.org\n  Manual:       https://meshroom-manual.readthedocs.io\n  Forum:        https://groups.google.com/g/alicevision\n  Tutorials:    https://www.youtube.com/c/AliceVisionOrg\n  Contribute:   https://github.com/alicevision/Meshroom\n''')\n\n\ngeneral_group = parser.add_argument_group('General Options')\ngeneral_group.add_argument(\n    '-i', '--input', \n    metavar='FILE FOLDER NODEINSTANCE=FILE,FOLDER,...', \n    type=str,\n    nargs='*',\n    default=[],\n    help='Input files and folders to process. '\n         'When multiple Init Nodes exist in the pipeline, inputs are applied to all by default. '\n         'To target a specific Init Node, use the format Node1=input1,input2 Node2=input3')\n\ngeneral_group.add_argument(\n    '-I', '--inputRecursive',\n    metavar='FOLDER FOLDER_2 NODEINSTANCE=FOLDER,FOLDER_2,...',\n    type=str,\n    nargs='*',\n    default=[],\n    help='Recursively scan these directories for input files.')\n\ngeneral_group.add_argument(\n    '-p', '--pipeline',\n    metavar='FILE.mg / PIPELINE',\n    type=str,\n    default=os.environ.get('MESHROOM_DEFAULT_PIPELINE', 'photogrammetry'),\n    help='Template pipeline among those listed or a Meshroom file containing a custom pipeline '\n         'to run on input images:\\n' +\n         '\\n'.join(['    - ' + p for p in meshroom.core.pipelineTemplates]))\n\ngeneral_group.add_argument(\n    '-o', '--output', \n    metavar='FOLDER COPYFILES_INSTANCE=FOLDER',\n    type=str,\n    required=False,\n    nargs='*',\n    help='Output folder for copying results. '\n         'Sets output folder for all CopyFiles nodes, or target specific nodes using COPYFILES_INSTANCE=FOLDER.')\n\ngeneral_group.add_argument(\n    '-s', '--save', metavar='FILE', type=str, required=False,\n    help='Save the configured Meshroom graph to a project file. It will setup the cache folder accordingly. ')\n\ngeneral_group.add_argument(\n    '--submit', help='Submit on renderfarm instead of local computation.',\n    action='store_true')\n\ngeneral_group.add_argument(\n    '-v', '--verbose',\n    help='Set the verbosity level for logging:\\n'\n         '  - fatal: Show only critical errors.\\n'\n         '  - error: Show errors only.\\n'\n         '  - warning: Show warnings and errors.\\n'\n         '  - info: Show standard informational messages.\\n'\n         '  - debug: Show detailed debug information.\\n'\n         '  - trace: Show all messages, including trace-level details.',\n    default=os.environ.get('MESHROOM_VERBOSE', 'warning'),\n    choices=['fatal', 'error', 'warning', 'info', 'debug', 'trace'])\n\nadvanced_group = parser.add_argument_group('Advanced Options')\nadvanced_group.add_argument(\n    '--overrides', metavar='SETTINGS', type=str, default=None,\n    help='A JSON file containing the graph parameters override.')\n\nadvanced_group.add_argument(\n    '--paramOverrides', metavar='NODETYPE:param=value NODEINSTANCE.param=value', type=str, default=None, nargs='*',\n    help='Override specific parameters directly from the command line (by node type or by node names).')\n\nadvanced_group.add_argument(\n    '--compute', metavar='<yes/no>', type=lambda x: bool(strtobool(x)), default=True, required=False,\n    help='You can set it to <no/false/0> to disable the computation.')\n\nadvanced_group.add_argument(\n    '--toNode', metavar='NODE', type=str, nargs='*',\n    default=None,\n    help='Process the node(s) with its dependencies.')\n\nadvanced_group.add_argument(\n    '--forceStatus', help='Force computation if status is RUNNING or SUBMITTED.',\n    action='store_true')\nadvanced_group.add_argument(\n    '--forceCompute', help='Compute in all cases even if already computed.',\n    action='store_true')\n\nadvanced_group.add_argument(\n    \"--submitLabel\",\n    type=str,\n    default=os.environ.get('MESHROOM_SUBMIT_LABEL', '[Meshroom] {projectName}'),\n    help=\"Label of a node when submitted on renderfarm.\")\n\nadvanced_group.add_argument(\n    '--submitter',\n    type=str,\n    default='Tractor',\n    help='Execute job with a specific submitter.')\n\nargs = parser.parse_args()\n\nlogging.getLogger().setLevel(logStringToPython[args.verbose])\n\nmeshroom.core.initPlugins()\nmeshroom.core.initNodes()\n\ngraph = meshroom.core.graph.Graph(name=args.pipeline)\n\nwith meshroom.core.graph.GraphModification(graph):\n    # initialize template pipeline\n    loweredPipelineTemplates = {k.lower(): v for k, v in meshroom.core.pipelineTemplates.items()}\n    if args.pipeline.lower() in loweredPipelineTemplates:\n        graph.initFromTemplate(loweredPipelineTemplates[args.pipeline.lower()],\n                               copyOutputs=True if args.output else False)\n    else:\n        # custom pipeline\n        graph.initFromTemplate(args.pipeline, copyOutputs=True if args.output else False)\n\n    if args.input:\n        # get init nodes\n        initNodes = graph.findInitNodes()\n        initNodesNames = [n.getName() for n in initNodes]\n\n        # parse inputs for each init node\n        mapInput = parseInitInputs(args.input)\n\n        # parse recursive inputs for each init node\n        mapInputRecursive = parseInitInputs(args.inputRecursive)\n\n        # check that input nodes exist in the pipeline template\n        for nodeName in mapInput.keys() | mapInputRecursive.keys():\n            if nodeName and nodeName not in initNodesNames:\n                raise RuntimeError(f\"Failed to find the Init Node '{nodeName}' in your pipeline.\\nAvailable Init Nodes: {initNodesNames}\")\n\n        # feed inputs (recursive and non-recursive paths) to corresponding init nodes\n        for initNode in initNodes:\n            nodeName = initNode.getName()\n            if nodeName not in mapInput | mapInputRecursive and \\\n               \"\" not in mapInput | mapInputRecursive:\n                continue\n\n            # Retrieve input per node and inputs for all init node types\n            input = mapInput.get(nodeName, []) + mapInput.get(\"\", [])\n            # Retrieve recursive inputs\n            inputRec = mapInputRecursive.get(nodeName, []) + mapInputRecursive.get(\"\", [])\n            initNode.nodeDesc.initialize(initNode, input, inputRec)\n\n    if not graph.canComputeLeaves:\n        raise RuntimeError(\"Graph cannot be computed. Check for compatibility issues.\")\n\n    if args.verbose:\n        graph.setVerbose(args.verbose)\n\n    if args.output:\n        # The output folders for CopyFiles nodes can be set as follows:\n        # - for each node, the output folder is specified following the\n        #   \"CopyFiles_name=/output/folder/path\" convention.\n        # - a path is provided without specifying which CopyFiles node should be set with it:\n        #   all the CopyFiles nodes will be set with it.\n        # - some CopyFiles nodes have their path specified, and another path is provided\n        #   without specifying a node: all CopyFiles nodes with dedicated will have their own\n        #   output folders set, and those which have not been specified will be set with the\n        #   other path.\n        # - some CopyFiles nodes have their output folder specified while others do not: all\n        #   the nodes with specified folders will use the provided values, and those without\n        #   any will be set with the output folder of the first specified CopyFiles node.\n        # - several output folders are provided without specifying any node: the last one will\n        #   be used to set all the CopyFiles nodes' output folders.\n\n        # Check that there is at least one CopyFiles node\n        copyNodes = graph.nodesOfType('CopyFiles')\n        if len(copyNodes) == 0:\n            raise RuntimeError('meshroom_batch requires a pipeline graph with at least ' +\n                               'one CopyFiles node, none found.')\n\n        reExtract = re.compile(r'(\\w+)=(.*)')  # NodeName=value\n        globalCopyPath = ''\n        for p in args.output:\n            result = reExtract.match(p)\n            if not result:  # If the argument is only a path, set it for the global path\n                globalCopyPath = p\n                continue\n\n            node, value = result.groups()\n            for i, n in enumerate(copyNodes):  # Find the correct CopyFiles node in the list\n                if n.name == node:  # If found, set the value, and remove it from the list\n                    n.output.value = value\n                    copyNodes.pop(i)\n                    if globalCopyPath == '':  # Fallback in case some nodes would have no path\n                        globalCopyPath = value\n                    break\n\n        for n in copyNodes:  # Set the remaining CopyPath nodes with the global path\n            n.output.value = globalCopyPath\n    else:\n        print(f'No output set, results will be available in the cache folder: \"{graph.cacheDir}\"')\n\n    if args.overrides:\n        with open(args.overrides, encoding='utf-8', errors='ignore') as f:\n            data = json.load(f)\n            for nodeName, overrides in data.items():\n                for attrName, value in overrides.items():\n                    graph.findNode(nodeName).attribute(attrName).value = value\n\n    if args.paramOverrides:\n        print(\"\\n\")\n        reExtract = re.compile(r'(\\w+)([:.])(\\w[\\w.]*)=(.*)')\n        for p in args.paramOverrides:\n            result = reExtract.match(p)\n            if not result:\n                raise ValueError('Invalid param override: ' + str(p))\n            node, t, param, value = result.groups()\n            if t == ':':\n                nodesOfType = graph.nodesOfType(node)\n                if not nodesOfType:\n                    raise ValueError(f'No node with the type \"{node}\" in the scene.')\n                for n in nodesOfType:\n                    print(f'Overrides {node}.{param}={value}')\n                    n.attribute(param).value = value\n            elif t == '.':\n                print(f'Overrides {node}.{param}={value}')\n                graph.findNode(node).attribute(param).value = value\n            else:\n                raise ValueError('Invalid param override: ' + str(p))\n        print(\"\\n\")\n\nif args.save:\n    graph.save(args.save)\n    print(f'File successfully saved: \"{args.save}\"')\n\n# find end nodes (None will compute all graph)\ntoNodes = graph.findNodes(args.toNode) if args.toNode else None\n\nif args.submit:\n    meshroom.core.initSubmitters()\n    if not args.save:\n        raise ValueError('Need to save the project to file to submit on renderfarm.')\n    # submit on renderfarm\n    meshroom.core.graph.submit(args.save, args.submitter, toNode=args.toNode,\n                               submitLabel=args.submitLabel)\nelif args.compute:\n    # find end nodes (None will compute all graph)\n    toNodes = graph.findNodes(args.toNode) if args.toNode else None\n    # start computation\n    meshroom.core.graph.executeGraph(graph, toNodes=toNodes, forceCompute=args.forceCompute,\n                                     forceStatus=args.forceStatus)\n"
  },
  {
    "path": "bin/meshroom_compute",
    "content": "#!/usr/bin/env python\nimport argparse\nimport logging\nimport os\nimport sys\nfrom typing import NoReturn\n\ntry:\n    import meshroom\nexcept Exception:\n    # If meshroom module is not in the PYTHONPATH, add our root using the relative path\n    import pathlib\n    meshroomRootFolder = pathlib.Path(__file__).parent.parent.resolve()\n    sys.path.append(meshroomRootFolder)\n    import meshroom\nmeshroom.setupEnvironment()\n\nimport meshroom.core\nimport meshroom.core.graph\nfrom meshroom.core.node import Status\n\n\nparser = argparse.ArgumentParser(description='Execute a Graph of processes.')\nparser.add_argument('graphFile', metavar='GRAPHFILE.mg', type=str,\n                    help='Filepath to a graph file.')\nparser.add_argument('--node', metavar='NODE_NAME', type=str,\n                    help='Process the node. It will generate an error if the dependencies are not already computed.')\nparser.add_argument('--toNode', metavar='NODE_NAME', type=str,\n                    help='Process the node with its dependencies.')\nparser.add_argument('--inCurrentEnv', help='Execute process in current env without creating a dedicated runtime environment.',\n                    action='store_true')\nparser.add_argument('--forceStatus', help='Force computation if status is RUNNING or SUBMITTED.',\n                    action='store_true')\nparser.add_argument('--forceCompute', help='Compute in all cases even if already computed.',\n                    action='store_true')\nparser.add_argument('--extern', help='Use this option when you compute externally after submission to a render farm from meshroom.',\n                    action='store_true')\nparser.add_argument('--cache', metavar='FOLDER', type=str,\n                    default=None,\n                    help='Override the cache folder')\nparser.add_argument('-v', '--verbose',\n                    help='Set the verbosity level for logging:\\n'\n                            '  - fatal: Show only critical errors.\\n'\n                            '  - error: Show errors only.\\n'\n                            '  - warning: Show warnings and errors.\\n'\n                            '  - info: Show standard informational messages.\\n'\n                            '  - debug: Show detailed debug information.\\n'\n                            '  - trace: Show all messages, including trace-level details.',\n                    default=os.environ.get('MESHROOM_VERBOSE', 'info'),\n                    choices=['fatal', 'error', 'warning', 'info', 'debug', 'trace'])\n\nparser.add_argument('-i', '--iteration', type=int,\n                    default=-1, help='')\n\nargs = parser.parse_args()\n\n# Setup the verbose level\nif args.extern:\n    # For extern computation, we want to focus on the node computation log.\n    # So, we avoid polluting the log with general warning about plugins, versions of nodes in file, etc.\n    logging.getLogger().setLevel(level=logging.ERROR)\nelse:\n    logging.getLogger().setLevel(meshroom.logStringToPython[args.verbose])\n\nmeshroom.core.initPlugins()\nmeshroom.core.initNodes()\nmeshroom.core.initSubmitters()\n\ngraph = meshroom.core.graph.loadGraph(args.graphFile)\nif args.cache:\n    graph.cacheDir = args.cache\ngraph.update()\n\n\ndef killRunningJob(node) -> NoReturn:\n    \"\"\" Kills current job and try to avoid job restarting \"\"\"\n    jobInfo = node.nodeStatus.jobInfo\n    submitterName = jobInfo.get(\"submitterName\")\n    if not submitterName:\n        sys.exit(meshroom.MeshroomExitStatus.ERROR_NO_RETRY)\n    from meshroom.core import submitters\n    for subName, sub in submitters.items():\n        if submitterName == subName:\n            sub.killRunningJob()\n            break\n    sys.exit(meshroom.MeshroomExitStatus.ERROR_NO_RETRY)\n\n\nif args.node:\n    node = graph.findNode(args.node)\n    node.updateStatusFromCache()\n    submittedStatuses = [Status.RUNNING]\n\n    if node.isCompatibilityNode:\n        print(f'{node.name} is in Compatibility Mode and cannot be computed.')\n        print(f'Compatibility issue: {node.issueDetails}')\n        sys.exit(1)\n\n    # Execute the node\n    if not args.extern:\n        # If running as \"extern\", the task is supposed to have the status SUBMITTED.\n        # If not running as \"extern\", the SUBMITTED status should generate a warning.\n        submittedStatuses.append(Status.SUBMITTED)\n\n    if args.iteration >= 0 and not node._chunksCreated:\n        print(f\"Error: Computing chunk {args.iteration} of node {node} before chunks have been created. \" \\\n              f\"See file: \\\"{node.nodeStatusFile}\\\".\")\n        sys.exit(-1)\n\n    if node.isInputNode:\n        print(f\"InputNode: No computation to do.\")\n        sys.exit(0)\n\n    if not args.forceStatus and not args.forceCompute:\n        if args.iteration != -1:\n            chunks = [node.chunks[args.iteration]]\n        else:\n            chunks = node.chunks\n        for chunk in chunks:\n            if chunk.status.status in submittedStatuses:\n                # Particular case for the local isolated, the node status is set to RUNNING by the submitter directly.\n                # We ensure that no other instance has started to compute, by checking that the computeSessionUid is empty.\n                if chunk.node.getMrNodeType() == meshroom.core.MrNodeType.NODE and \\\n                    not chunk.status.computeSessionUid and node._nodeStatus.submitterSessionUid:\n                    continue\n                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}')\n                # sys.exit(-1)\n\n    if args.extern:\n        # Restore the log level\n        logging.getLogger().setLevel(meshroom.logStringToPython[args.verbose])\n\n    node.prepareLogger(args.iteration)\n    node.preprocess()\n    if args.iteration != -1:\n        chunk = node.chunks[args.iteration]\n        if chunk._status.status == Status.STOPPED:\n            print(f\"Chunk {chunk}: status is STOPPED\")\n            killRunningJob(node)\n        chunk.process(args.forceCompute, args.inCurrentEnv)\n    else:\n        if node.nodeStatus.status == Status.STOPPED:\n            print(f\"Node {node}: status is STOPPED\")\n            killRunningJob(node)\n        node.createChunks()\n        node.process(args.forceCompute, args.inCurrentEnv)\n    node.postprocess()\n    node.restoreLogger()\nelse:\n    if args.iteration != -1:\n        print('Error: \"--iteration\" only makes sense when used with \"--node\".')\n        sys.exit(-1)\n    toNodes = None\n    if args.toNode:\n        toNodes = graph.findNodes([args.toNode])\n\n    meshroom.core.graph.executeGraph(graph, toNodes=toNodes, forceCompute=args.forceCompute, forceStatus=args.forceStatus)\n"
  },
  {
    "path": "bin/meshroom_createChunks",
    "content": "#!/usr/bin/env python\n\n\"\"\"\nThis is a script used to wrap the process of processing a node on the farm\nIt will handle chunk creation and create all the jobs for these chunks\nIf the submitter cannot create chunks, then it will process the chunks serially\nin the current process\n\"\"\"\n\nimport argparse\nimport logging\nimport os\nimport sys\ntry:\n    import meshroom\nexcept Exception:\n    # If meshroom module is not in the PYTHONPATH, add our root using the relative path\n    import pathlib\n    meshroomRootFolder = pathlib.Path(__file__).parent.parent.resolve()\n    sys.path.append(meshroomRootFolder)\n    import meshroom\nmeshroom.setupEnvironment()\n\nimport meshroom.core\nimport meshroom.core.graph\nfrom meshroom.core import submitters\nfrom meshroom.core.submitter import SubmitterOptionsEnum\nfrom meshroom.core.node import Status\n\n\nparser = argparse.ArgumentParser(description='Execute a Graph of processes.')\nparser.add_argument('graphFile', metavar='GRAPHFILE.mg', type=str,\n                    help='Filepath to a graph file.')\n\nparser.add_argument('--submitter', type=str, required=True,\n                    help='Name of the submitter used to create the job.')\nparser.add_argument('--node', metavar='NODE_NAME', type=str, required=True,\n                    help='Process the node. It will generate an error if the dependencies are not already computed.')\nparser.add_argument('--inCurrentEnv', help='Execute process in current env without creating a dedicated runtime environment.',\n                    action='store_true')\nparser.add_argument('--forceStatus', help='Force computation if status is RUNNING or SUBMITTED.',\n                    action='store_true')\nparser.add_argument('--forceCompute', help='Compute in all cases even if already computed.',\n                    action='store_true')\nparser.add_argument('--extern', help='Use this option when you compute externally after submission to a render farm from meshroom.',\n                    action='store_true')\nparser.add_argument('--cache', metavar='FOLDER', type=str,\n                    default=None,\n                    help='Override the cache folder')\nparser.add_argument('-v', '--verbose',\n                    help='Set the verbosity level for logging:\\n'\n                            '  - fatal: Show only critical errors.\\n'\n                            '  - error: Show errors only.\\n'\n                            '  - warning: Show warnings and errors.\\n'\n                            '  - info: Show standard informational messages.\\n'\n                            '  - debug: Show detailed debug information.\\n'\n                            '  - trace: Show all messages, including trace-level details.',\n                    default=os.environ.get('MESHROOM_VERBOSE', 'info'),\n                    choices=['fatal', 'error', 'warning', 'info', 'debug', 'trace'])\n\nargs = parser.parse_args()\n\n# For extern computation, we want to focus on the node computation log.\n# So, we avoid polluting the log with general warning about plugins, versions of nodes in file, etc.\nlogging.getLogger().setLevel(level=logging.INFO)\n\nmeshroom.core.initPlugins()\nmeshroom.core.initNodes()\nmeshroom.core.initSubmitters()  # Required to spool child job\n\ngraph = meshroom.core.graph.loadGraph(args.graphFile)\nif args.cache:\n    graph.cacheDir = args.cache\ngraph.update()\n\n# Execute the node\nnode = graph.findNode(args.node)\nsubmittedStatuses = [Status.RUNNING]\n\n# Find submitter\nsubmitter = None\n# It's required if we want to spool chunks on different machines\nfor subName, sub in submitters.items():\n    if args.submitter == subName:\n        submitter = sub\n        break\n\nif node._nodeStatus.status in (Status.STOPPED, Status.KILLED):\n    logging.error(\"Node status is STOPPED or KILLED.\")\n    if submitter:\n        submitter.killRunningJob()\n    sys.exit(meshroom.MeshroomExitStatus.ERROR_NO_RETRY)\n\nif not node._chunksCreated:\n    # Create node chunks\n    # Once created we don't have to do it again even if we relaunch the job\n    node.createChunks()\n    # Set the chunks statuses\n    for chunk in node._chunks:\n        if args.forceCompute or chunk._status.status != Status.SUCCESS:\n            hasChunkToLaunch = True\n            chunk._status.setNode(node)\n            chunk._status.initExternSubmit()\n            chunk.upgradeStatusFile()\n\n# Get chunks to process in the current process\nchunksToProcess = []\nif submitter:\n    if not submitter._options.includes(SubmitterOptionsEnum.EDIT_TASKS):\n        chunksToProcess = node.chunks\nelse:\n    # Cannot retrieve job -> execute process serially\n    chunksToProcess = node.chunks\n\nlogging.info(f\"[MeshroomCreateChunks] Chunks to process here : {chunksToProcess}\")\n\nif not args.forceStatus and not args.forceCompute:\n    for chunk in chunksToProcess:\n        if chunk.status.status in submittedStatuses:\n            # Particular case for the local isolated, the node status is set to RUNNING by the submitter directly.\n            # We ensure that no other instance has started to compute, by checking that the sessicomputeSessionUidonUid is empty.\n            if chunk.node.getMrNodeType() == meshroom.core.MrNodeType.NODE and \\\n                not chunk.status.computeSessionUid and node._nodeStatus.submitterSessionUid:\n                continue\n            logging.warning(\n                f\"[MeshroomCreateChunks] Node is already submitted with status \" \\\n                f\"\\\"{chunk.status.status.name}\\\". See file: \\\"{chunk.statusFile}\\\". \" \\\n                f\"ExecMode: {chunk.status.execMode.name}, computeSessionUid: {chunk.status.computeSessionUid}, \" \\\n                f\"submitterSessionUid: {node._nodeStatus.submitterSessionUid}\")\n\nif chunksToProcess:\n    node.prepareLogger()\n    node.preprocess()\n    for chunk in chunksToProcess:\n        logging.info(f\"[MeshroomCreateChunks] process chunk {chunk}\")\n        chunk.process(args.forceCompute, args.inCurrentEnv)\n    node.postprocess()\n    node.restoreLogger()\nelse:\n    logging.info(f\"[MeshroomCreateChunks] -> create job to process chunks {[c for c in node.chunks]}\")\n    submitter.createChunkTask(node, graphFile=args.graphFile, cache=args.cache, \n                              forceStatus=args.forceStatus, forceCompute=args.forceCompute)\n\n# Restore the log level\nlogging.getLogger().setLevel(meshroom.logStringToPython[args.verbose])\n"
  },
  {
    "path": "bin/meshroom_newNodeType",
    "content": "#!/usr/bin/env python\n\nimport argparse\nimport os\nimport re\nimport sys\nimport shlex\nfrom pprint import pprint\n\ndef trim(s):\n    \"\"\"\n    All repetition of any kind of space is replaced by a single space\n    and remove trailing space at beginning or end.\n    \"\"\"\n    # regex to replace all space groups by a single space\n    # use split() to remove trailing space at beginning/end\n    return re.sub(r'\\s+', ' ', s).strip()\n\n\ndef quotesForStrings(valueStr):\n    \"\"\"\n    Return the input string with quotes if it cannot be cast into another builtin type.\n    \"\"\"\n    v = valueStr\n    try:\n        int(valueStr)\n    except ValueError:\n        try:\n            float(valueStr)\n        except ValueError:\n            if \"'\" in valueStr:\n                v = f\"'''{valueStr}'''\"\n            else:\n                v = f\"'{valueStr}'\"\n    return v\n\ndef convertToLabel(name):\n    camelCaseToLabel = re.sub('()([A-Z][a-z]*?)', r'\\1 \\2', name)\n    snakeToLabel = ' '.join(word.capitalize() for word in camelCaseToLabel.split('_'))\n    snakeToLabel = ' '.join(word.capitalize() for word in snakeToLabel.split(' '))\n    return snakeToLabel\n\ndef is_int(s):\n    try:\n        int(s)\n        return True\n    except ValueError:\n        return False\n\ndef is_float(s):\n    try:\n        float(s)\n        return True\n    except ValueError:\n        return False\n\n\nparser = argparse.ArgumentParser(description='Create a new Node Type')\nparser.add_argument('node', metavar='NODE_NAME', type=str,\n                    help='New node name')\nparser.add_argument('bin', metavar='CMDLINE', type=str,\n                    default=None,\n                    help='Input executable')\nparser.add_argument('--output', metavar='DIR', type=str,\n                    default=os.path.dirname(__file__),\n                    help='Output plugin folder')\nparser.add_argument('--parser', metavar='PARSER', type=str,\n                    default='boost',\n                    help='Select the parser adapted for your command line: {boost,cmdLineLib,basic}.')\nparser.add_argument(\"--force\", help=\"Allows to overwrite the output plugin file.\",\n                    action=\"store_true\")\n\nargs = parser.parse_args()\n\ninputCmdLineDoc = None\nsoft = \"{nodeType}\"\nif args.bin:\n    soft = args.bin\n    import subprocess\n    proc = subprocess.Popen(args=shlex.split(args.bin) + ['--help'], stdout=subprocess.PIPE, stderr=subprocess.PIPE)\n    stdout, stderr = proc.communicate()\n    inputCmdLineDoc = stdout if stdout else stderr\nelif sys.stdin.isatty():\n    inputCmdLineDoc = ''.join([line for line in sys.stdin])\n\nif not inputCmdLineDoc:\n    print('No input documentation.')\n    print(f'Usage: YOUR_COMMAND --help | {os.path.splitext(__file__)[0]}')\n    sys.exit(-1)\n\n\nfileStr = '''import sys\nfrom meshroom.core import desc\n\n\nclass __COMMANDNAME__(desc.CommandLineNode):\n    commandLine = '__SOFT__ {allParams}'\n'''.replace('__COMMANDNAME__', args.node).replace('__SOFT__', soft)\n\n\nprint(inputCmdLineDoc)\n\nargs_re = None\nif args.parser == 'boost':\n    args_re = re.compile(\n        r'^\\s+'  # space(s)\n        r'(?:-(?P<argShortName>\\w+)\\|?)?'  # potential argument short name\n        r'\\s*\\[?'  # potential '['\n        r'\\s*--(?P<argLongName>\\w+)'  # argument long name\n        r'(?:\\s*\\])?' # potential ']'\n        r'(?:\\s+(?P<arg>\\w+)?)?'  # potential arg\n        r'(?:\\s+\\(\\=(?P<defaultValue>.+)\\))?'  # potential default value\n        r'\\s+(?P<descriptionFirst>.*?)\\n'  # end of the line\n        r'(?P<descriptionNext>(?:\\s+[^-\\s].+?\\n)*)'  # next documentation lines\n        , re.MULTILINE)\nelif args.parser == 'cmdLineLib':\n    args_re = re.compile(\n        '^'\n        r'\\[' # '['\n        r'-(?P<argShortName>\\w+)'  # argument short name\n        r'\\|'\n        r'--(?P<argLongName>\\w+)'  # argument long name\n        r'(?:\\s+(?P<arg>\\w+)?)?'  # potential arg\n        r'\\]' # ']'\n        r'()' # no default value\n        r'(?P<descriptionFirst>.*?)?\\n' # end of the line\n        r'(?P<descriptionNext>(?:[^\\[\\w].+?\\n)*)' # next documentation lines\n        , re.MULTILINE)\nelif args.parser == 'basic':\n    args_re = re.compile(r'()--(?P<argLongName>\\w+)()()()()')\nelse:\n    print(f'Error: Unknown input parser \"{args.parser}\"')\n    sys.exit(-1)\n\nchoiceValues1_re = re.compile(r'\\* (?P<value>\\w+):')\nchoiceValues2_re = re.compile(r'\\((?P<value>.+?)\\)')\nchoiceValues3_re = re.compile(r'\\{(?P<value>.+?)\\}')\n\ncmdLineArgs = args_re.findall(inputCmdLineDoc.decode('utf-8'))\n\nprint('='*80)\npprint(cmdLineArgs)\n\noutputNodeStr = ''\ninputNodeStr = ''\n\nfor cmdLineArg in cmdLineArgs:\n    shortName = cmdLineArg[0]\n    longName = cmdLineArg[1]\n    if longName == 'help':\n        continue  # skip help argument\n\n    arg = cmdLineArg[2]\n    value = cmdLineArg[3]\n    descLines = cmdLineArg[4:]\n    description = ''.join(descLines).strip()\n    if description.endswith(':'):\n        # If documentation is multiple lines and the last line ends with ':',\n        # we remove this last line as it is probably the title of the next group of options\n        description = '\\n'.join(description.split('\\n')[:-1])\n    description = trim(description)\n\n    values = choiceValues1_re.findall(description)\n    if not values:\n        possibleLists = choiceValues2_re.findall(description) + choiceValues3_re.findall(description)\n        for possibleList in possibleLists:\n            candidate = possibleList.split(',')\n            if len(candidate) > 1:\n                values = [trim(v) for v in candidate]\n\n    cmdLineArgLower = ' '.join([shortName, longName, arg, value, description]).lower()\n    namesLower = ' '.join([shortName, longName]).lower()\n    isBool = (arg == '' and value == '')\n    isFile = 'path' in cmdLineArgLower or 'folder' in cmdLineArgLower or 'file' in cmdLineArgLower\n    isChoice = bool(values)\n    isOutput = 'output' in cmdLineArgLower or 'out' in namesLower\n    isInt = is_int(value)\n    isFloat = is_float(value)\n\n    argStr = None\n    if isBool:\n        argStr = \"\"\"\n        desc.BoolParam(\n            name='{name}',\n            label='{label}',\n            description='''{description}''',\n            value={value},\n            ),\"\"\".format(\n                name=longName,\n                label=convertToLabel(longName),\n                description=description,\n                value=quotesForStrings(value),\n                arg=arg,\n                )\n    elif isFile:\n        argStr = \"\"\"\n        desc.File(\n            name='{name}',\n            label='{label}',\n            description='''{description}''',\n            value={value},\n            ),\"\"\".format(\n                name=longName,\n                label=convertToLabel(longName),\n                description=description,\n                value=quotesForStrings(value),\n                arg=arg,\n                )\n    elif isChoice:\n        argStr = \"\"\"\n        desc.ChoiceParam(\n            name='{name}',\n            label='{label}',\n            description='''{description}''',\n            value={value},\n            values={values},\n            exclusive={exclusive},\n            ),\"\"\".format(\n                name=longName,\n                label=convertToLabel(longName),\n                description=description,\n                value=quotesForStrings(value),\n                values=values,\n                exclusive=True,\n                )\n    elif isInt:\n        argStr = \"\"\"\n        desc.IntParam(\n            name='{name}',\n            label='{label}',\n            description='''{description}''',\n            value={value},\n            range={range},\n            ),\"\"\".format(\n                name=longName,\n                label=convertToLabel(longName),\n                description=description,\n                value=value,\n                range='(-sys.maxsize, sys.maxsize, 1)',\n                )\n    elif isFloat:\n        argStr = \"\"\"\n        desc.FloatParam(\n            name='{name}',\n            label='{label}',\n            description='''{description}''',\n            value={value},\n            range={range},\n            ),\"\"\".format(\n                name=longName,\n                label=convertToLabel(longName),\n                description=description,\n                value=value,\n                range='''(-float('inf'), float('inf'), 0.01)''',\n                )\n    else:\n        argStr = \"\"\"\n        desc.StringParam(\n            name='{name}',\n            label='{label}',\n            description='''{description}''',\n            value={value},\n            ),\"\"\".format(\n                name=longName,\n                label=convertToLabel(longName),\n                description=description,\n                value=quotesForStrings(value),\n                range=range,\n                )\n    if isOutput:\n        outputNodeStr += argStr\n    else:\n        inputNodeStr += argStr\n\n\nfileStr += \"\"\"\n    inputs = [\"\"\" + inputNodeStr + \"\"\"\n    ]\n\n    outputs = [\"\"\" + outputNodeStr + \"\"\"\n    ]\n\"\"\"\n\noutputFilepath = os.path.join(args.output, args.node + '.py')\n\nif not args.force and os.path.exists(outputFilepath):\n    print(f'Plugin \"{args.node}\" already exists \"{outputFilepath}\".')\n    sys.exit(-1)\n\nwith open(outputFilepath, 'w') as pluginFile:\n    pluginFile.write(fileStr)\n\nprint(f'New node exported to: \"{outputFilepath}\"')\n"
  },
  {
    "path": "bin/meshroom_statistics",
    "content": "#!/usr/bin/env python\nimport argparse\nimport os\nimport sys\nimport logging\nfrom collections import defaultdict\nfrom collections.abc import Iterable\n\nimport meshroom\nfrom meshroom.core import graph as pg\n\n\ndef addPlots(curves, title, fileObj):\n    if not curves:\n        return\n\n    import matplotlib.pyplot as plt, mpld3\n\n    fig = plt.figure()\n    ax = fig.add_subplot(111, facecolor='#EEEEEE')\n    ax.grid(color='white', linestyle='solid')\n\n    for curveName, curve in curves:\n        if not isinstance(curve[0], str):\n            ax.plot(curve, label=curveName)\n    ax.legend()\n    # plt.ylim(0, 100)\n    plt.title(title)\n\n    mpld3.save_html(fig, fileObj)\n    plt.close(fig)\n\n\nparser = argparse.ArgumentParser(description='Query the status of nodes in a Graph of processes.')\nparser.add_argument('graphFile', metavar='GRAPHFILE.mg', type=str,\n                    help='Filepath to a graph file.')\nparser.add_argument('--node', metavar='NODE_NAME', type=str,\n                    help='Process the node alone.')\nparser.add_argument('--graph', metavar='NODE_NAME', type=str,\n                    help='Process the node and all previous nodes needed.')\nparser.add_argument('--exportHtml', metavar='FILE', type=str,\n                    help='Filepath to the output html file.')\nparser.add_argument('-v', '--verbose',\n                    help='Set the verbosity level for logging:\\n'\n                            '  - fatal: Show only critical errors.\\n'\n                            '  - error: Show errors only.\\n'\n                            '  - warning: Show warnings and errors.\\n'\n                            '  - info: Show standard informational messages.\\n'\n                            '  - debug: Show detailed debug information.\\n'\n                            '  - trace: Show all messages, including trace-level details.',\n                    default=os.environ.get('MESHROOM_VERBOSE', 'info'),\n                    choices=['fatal', 'error', 'warning', 'info', 'debug', 'trace'])\n\nargs = parser.parse_args()\n\nif not os.path.exists(args.graphFile):\n    print(f'ERROR: No graph file \"{args.graphFile}\".')\n    sys.exit(-1)\n\nlogging.getLogger().setLevel(meshroom.logStringToPython[args.verbose])\nmeshroom.core.initPlugins()\nmeshroom.core.initNodes()\n\ngraph = pg.loadGraph(args.graphFile)\n\ngraph.update()\ngraph.updateStatisticsFromCache()\n\nnodes = []\nif args.node:\n    nodes = [graph.findNode(args.node)]\nelse:\n    startNodes = None\n    if args.graph:\n        startNodes = [graph.node(args.graph)]\n    nodes, edges = graph.dfsOnFinish(startNodes=startNodes)\n\nfor node in nodes:\n    for chunk in node.chunks:\n        print(f'{chunk.name}: {chunk.statistics.toDict()}\\n')\n\nif args.exportHtml:\n    with open(args.exportHtml, 'w') as fileObj:\n        for node in nodes:\n            for chunk in node.chunks:\n                for curves in (chunk.statistics.computer.curves, chunk.statistics.process.curves):\n                    exportCurves = defaultdict(list)\n                    for name, curve in curves.items():\n                        s = name.split('.')\n                        figName = s[0]\n                        curveName = ''.join(s[1:])\n                        exportCurves[figName].append((curveName, curve))\n\n                    for name, curves in exportCurves.items():\n                        addPlots(curves, name, fileObj)\n"
  },
  {
    "path": "bin/meshroom_status",
    "content": "#!/usr/bin/env python\nimport argparse\nimport os\nimport sys\nimport pprint\nimport logging\n\nimport meshroom\nmeshroom.setupEnvironment()\n\nimport meshroom.core.graph\n\nparser = argparse.ArgumentParser(description='Query the status of nodes in a Graph of processes.')\nparser.add_argument('graphFile', metavar='GRAPHFILE.mg', type=str,\n                    help='Filepath to a graph file.')\nparser.add_argument('--node', metavar='NODE_NAME', type=str,\n                    help='Process the node alone.')\nparser.add_argument('--toNode', metavar='NODE_NAME', type=str,\n                    help='Process the node and all previous nodes needed.')\nparser.add_argument('-v', '--verbose',\n                    help='Set the verbosity level for logging:\\n'\n                            '  - fatal: Show only critical errors.\\n'\n                            '  - error: Show errors only.\\n'\n                            '  - warning: Show warnings and errors.\\n'\n                            '  - info: Show standard informational messages.\\n'\n                            '  - debug: Show detailed debug information.\\n'\n                            '  - trace: Show all messages, including trace-level details.',\n                    default=os.environ.get('MESHROOM_VERBOSE', 'info'),\n                    choices=['fatal', 'error', 'warning', 'info', 'debug', 'trace'])\n\nargs = parser.parse_args()\n\nif not os.path.exists(args.graphFile):\n    print(f'ERROR: No graph file \"{args.node}\".')\n    sys.exit(-1)\n\nlogging.getLogger().setLevel(meshroom.logStringToPython[args.verbose])\nmeshroom.core.initPlugins()\nmeshroom.core.initNodes()\n\n\ngraph = meshroom.core.graph.loadGraph(args.graphFile)\n\ngraph.update()\n\nif args.node:\n    node = graph.node(args.node)\n    if node is None:\n        logging.error(f'Node \"{args.node}\" does not exist in file \"{args.graphFile}\".')\n        sys.exit(-1)\n    for chunk in node.chunks:\n        print(f'{chunk.name}: {chunk.status.status.name}')\n    logging.debug(f'nodeStatusFile: {node.nodeStatusFile}')\n    logging.debug(pprint.pformat(node.nodeStatus.toDict()))\nelse:\n    startNodes = None\n    if args.toNode:\n        startNodes = [graph.findNode(args.toNode)]\n    nodes, edges = graph.dfsOnFinish(startNodes=startNodes)\n    for node in nodes:\n        for chunk in node.chunks:\n            print(f'{chunk.name}: {chunk.nodeStatus.status.name}')\n    logging.debug(pprint.pformat([n.status.toDict() for n in nodes]))\n"
  },
  {
    "path": "bin/meshroom_submit",
    "content": "#!/usr/bin/env python\nimport argparse\n\nimport meshroom\nmeshroom.setupEnvironment()\n\nimport meshroom.core.graph\n\nparser = argparse.ArgumentParser(description='Submit a Graph of processes on renderfarm.')\nparser.add_argument('meshroomFile', metavar='MESHROOMFILE.mg', type=str,\n                    help='Filepath to a graph file.')\nparser.add_argument('--toNode', metavar='NODE_NAME', type=str,\n                    help='Process the node with its dependencies.')\nparser.add_argument('--submitter',\n                    type=str,\n                    default='Tractor',\n                    help='Execute job with a specific submitter.')\nparser.add_argument(\"--submitLabel\",\n                    type=str,\n                    default='[Meshroom] {projectName}',\n                    help=\"Label of a node in the submitter\")\n\nargs = parser.parse_args()\n\nmeshroom.core.initPlugins()\nmeshroom.core.initNodes()\nmeshroom.core.initSubmitters()\n\nmeshroom.core.graph.submit(args.meshroomFile, args.submitter, toNode=args.toNode, submitLabel=args.submitLabel)\n"
  },
  {
    "path": "dev_requirements.txt",
    "content": "# packaging\ncx_Freeze==7.2.10\n\n# Python binding packaging\nnumpy==1.*\n\n# testing\npytest\n"
  },
  {
    "path": "docker/Dockerfile_rocky",
    "content": "ARG MESHROOM_VERSION\nARG AV_VERSION\nARG CUDA_VERSION\nARG ROCKY_VERSION\nFROM alicevision/meshroom-deps:${MESHROOM_VERSION}-av${AV_VERSION}-rocky${ROCKY_VERSION}-cuda${CUDA_VERSION}\nLABEL maintainer=\"AliceVision Team alicevision-team@googlegroups.com\"\n\n# Execute with nvidia docker (https://github.com/nvidia/nvidia-docker/wiki/Installation-(version-2.0))\n# docker run -it --runtime nvidia -p 2222:22 --name meshroom -v</path/to/your/data>:/data alicevision/meshroom:develop-av2.2.8.develop-ubuntu20.04-cuda11.0\n# ssh -p 2222 -X root@<docker host> /opt/Meshroom_bundle/Meshroom # Password is 'meshroom'\n\nRUN dnf install -y patchelf\n\nENV MESHROOM_DEV=/opt/Meshroom \\\n    MESHROOM_BUILD=/tmp/Meshroom_build \\\n    MESHROOM_BUNDLE=/opt/Meshroom_bundle \\\n    AV_INSTALL=/opt/AliceVision_install \\\n    QT_DIR=/opt/Qt/6.8.3/gcc_64 \\\n    PATH=\"${PATH}:${MESHROOM_BUNDLE}\"\n\nCOPY *.txt *.md *.py ${MESHROOM_DEV}/\nCOPY ./docs ${MESHROOM_DEV}/docs\nCOPY ./meshroom ${MESHROOM_DEV}/meshroom\nCOPY ./tests ${MESHROOM_DEV}/tests\nCOPY ./bin ${MESHROOM_DEV}/bin\n\nWORKDIR ${MESHROOM_DEV}\n\n# Generate the exe for Meshroom and clean-up the bundle folder\nRUN python setup.py install_exe -d \"${MESHROOM_BUNDLE}\" && \\\n        find ${MESHROOM_BUNDLE} -name \"*Qt6Web*\" -delete && \\\n        find ${MESHROOM_BUNDLE} -name \"*Qt6Designer*\" -delete && \\\n        rm -rf ${MESHROOM_BUNDLE}/lib/PySide6/typesystems/ \\\n                ${MESHROOM_BUNDLE}/lib/PySide6/examples/ \\\n                ${MESHROOM_BUNDLE}/lib/PySide6/include/ \\\n                ${MESHROOM_BUNDLE}/lib/PySide6/Qt/translations/ \\\n                ${MESHROOM_BUNDLE}/lib/PySide6/Qt/resources/ \\\n                ${MESHROOM_BUNDLE}/lib/PySide6/QtWeb* \\\n                ${MESHROOM_BUNDLE}/lib/PySide6/rcc \\\n                ${MESHROOM_BUNDLE}/lib/PySide6/designer\n\nWORKDIR ${MESHROOM_BUILD}\n\n# Move the bundled installation of AliceVision into Meshroom's bundle\nRUN mkdir ${MESHROOM_BUNDLE}/aliceVision && \\\n    mv /opt/AliceVision_bundle/* ${MESHROOM_BUNDLE}/aliceVision\n\n# Build Meshroom plugins\nRUN cmake \"${MESHROOM_DEV}\" -DALICEVISION_ROOT=\"${AV_INSTALL}\" -DCMAKE_INSTALL_PREFIX=\"${MESHROOM_BUNDLE}/qtPlugins\"\nRUN make \"-j$(nproc)\" QtAliceVision\nRUN make \"-j$(nproc)\" && \\\n\trm -rf \"${MESHROOM_BUILD}\" \"${MESHROOM_DEV}\" \\\n\t\t${MESHROOM_BUNDLE}/aliceVision/share/doc \\\n\t\t${MESHROOM_BUNDLE}/aliceVision/share/eigen3 \\\n\t\t${MESHROOM_BUNDLE}/aliceVision/share/fonts \\\n\t\t${MESHROOM_BUNDLE}/aliceVision/share/lemon \\\n\t\t${MESHROOM_BUNDLE}/aliceVision/share/libraw \\\n\t\t${MESHROOM_BUNDLE}/aliceVision/share/man/ \\\n\t\taliceVision/share/pkgconfig\n\n# PySide6: copy missing libQt63DQuickScene3D.so along with its dependencies to avoid runtime issues\nRUN cp ${QT_DIR}/lib/libQt63DQuickScene3D.so.6.8.3 ${MESHROOM_BUNDLE}/lib/PySide6/Qt/qml/QtQuick/Dialogs && \\\n    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 && \\\n    cp ${MESHROOM_BUNDLE}/lib/PySide6/Qt/lib/libQt6Concurrent.so.6 ${MESHROOM_BUNDLE}/lib/PySide6/Qt/qml/QtQuick/Dialogs && \\\n    cp ${MESHROOM_BUNDLE}/lib/PySide6/Qt/qml/Qt3D/Animation/libQt63DAnimation.so.6 ${MESHROOM_BUNDLE}/lib/PySide6/Qt/qml/QtQuick/Dialogs && \\\n    cp ${MESHROOM_BUNDLE}/lib/PySide6/Qt/qml/Qt3D/Core/libQt63DCore.so.6 ${MESHROOM_BUNDLE}/lib/PySide6/Qt/qml/QtQuick/Dialogs && \\\n    cp ${MESHROOM_BUNDLE}/lib/PySide6/Qt/qml/Qt3D/Input/libQt63DInput.so.6 ${MESHROOM_BUNDLE}/lib/PySide6/Qt/qml/QtQuick/Dialogs && \\\n    cp ${MESHROOM_BUNDLE}/lib/PySide6/Qt/qml/Qt3D/Logic/libQt63DLogic.so.6 ${MESHROOM_BUNDLE}/lib/PySide6/Qt/qml/QtQuick/Dialogs && \\\n    cp ${MESHROOM_BUNDLE}/lib/PySide6/Qt/qml/Qt3D/Render/libQt63DRender.so.6 ${MESHROOM_BUNDLE}/lib/PySide6/Qt/qml/QtQuick/Dialogs\n\n# Copy libOpenGL in the bundle: needed by QtAliceVision as a side effect of a Qt6 bug\nRUN cp /usr/lib64/libOpenGL.so.0.0.0 ${MESHROOM_BUNDLE}/lib\nRUN mv ${MESHROOM_BUNDLE}/lib/libOpenGL.so.0.0.0 ${MESHROOM_BUNDLE}/lib/libOpenGL.so.0\n\n# Enable SSH X11 forwarding, needed when the Docker image\n# is run on a remote machine\nRUN dnf install -y openssh openssh-clients openssh-server xorg-x11-xauth\nRUN systemctl enable sshd && \\\n    mkdir -p /run/sshd && \\\n    ssh-keygen -A\n\nRUN sed -i \"s/^.*X11Forwarding.*$/X11Forwarding yes/; s/^.*X11UseLocalhost.*$/X11UseLocalhost no/; s/^.*PermitRootLogin prohibit-password/PermitRootLogin yes/; s/^.*X11UseLocalhost.*/X11UseLocalhost no/;\" /etc/ssh/sshd_config\nRUN echo \"root:meshroom\" | chpasswd\n\nWORKDIR /root\n\nEXPOSE 22\nCMD [\"/usr/sbin/sshd\", \"-D\"]\n\n"
  },
  {
    "path": "docker/Dockerfile_rocky_deps",
    "content": "ARG AV_VERSION\nARG CUDA_VERSION\nARG ROCKY_VERSION\nFROM alicevision/alicevision:${AV_VERSION}-rocky${ROCKY_VERSION}-cuda${CUDA_VERSION}\nLABEL maintainer=\"AliceVision Team alicevision-team@googlegroups.com\"\n\n# Execute with nvidia docker (https://github.com/nvidia/nvidia-docker/wiki/Installation-(version-2.0))\n# docker run -it --runtime=nvidia meshroom\n\nENV MESHROOM_DEV=/opt/Meshroom \\\n    MESHROOM_BUILD=/tmp/Meshroom_build \\\n    QT_DIR=/opt/Qt/6.8.3/gcc_64 \\\n    QT_CI_LOGIN=alicevisionjunk@gmail.com \\\n    QT_CI_P=azerty1.\n\n# Install libs needed by Qt\nRUN dnf update -y\nRUN dnf install -y flex fontconfig freetype glib2-devel libICE\nRUN dnf install -y libX11 libXext libXi libXrender libSM\nRUN dnf install -y libXt-devel mesa-libGLU-devel mesa-libOSMesa-devel mesa-libGL-devel mesa-libEGL-devel\nRUN dnf install -y zlib-devel systemd openssh-server\nRUN dnf install -y libxcb-devel \\\n                   libxkbcommon-devel \\\n                   libxkbcommon-x11-devel \\\n                   xcb-util-wm xcb-util-image \\\n                   xcb-util-keysyms \\\n                   xcb-util-renderutil\nRUN dnf install -y libglvnd-opengl\n\n# Install Qt (to build plugins)\nWORKDIR /tmp/qt\nCOPY dl/qt.run /tmp/qt\nRUN chmod +x qt.run\nRUN ./qt.run --root /opt/Qt --verbose --email ${QT_CI_LOGIN} --password ${QT_CI_P} --accept-obligations \\\n    --accept-licenses --default-answer --platform minimal --auto-answer installationErrorWithCancel=Ignore \\\n    --no-force-installations --no-default-installations --confirm-command \\\n    install qt.qt6.683.linux_gcc_64 qt.qt6.683.addons.qtcharts qt.qt6.683.addons.qt3d\nRUN rm qt.run\n\n# Strip sections containing \".note.ABI.tag\" from .so: https://github.com/Microsoft/WSL/issues/3023\nRUN find ${QT_DIR}/lib/ -name '*.so' | xargs strip --remove-section=.note.ABI-tag\n\nCOPY ./*requirements.txt ${MESHROOM_DEV}/\n\n# Install Meshroom requirements and freeze bundle\nWORKDIR \"${MESHROOM_DEV}\"\nRUN python -m pip install -r dev_requirements.txt -r requirements.txt\n"
  },
  {
    "path": "docker/Dockerfile_ubuntu",
    "content": "ARG MESHROOM_VERSION\nARG AV_VERSION\nARG CUDA_VERSION\nARG UBUNTU_VERSION\nFROM alicevision/meshroom-deps:${MESHROOM_VERSION}-av${AV_VERSION}-ubuntu${UBUNTU_VERSION}-cuda${CUDA_VERSION}\nLABEL maintainer=\"AliceVision Team alicevision-team@googlegroups.com\"\n\n# Execute with nvidia docker (https://github.com/nvidia/nvidia-docker/wiki/Installation-(version-2.0))\n# docker run -it --runtime nvidia -p 2222:22 --name meshroom -v</path/to/your/data>:/data alicevision/meshroom:develop-av2.2.8.develop-ubuntu20.04-cuda11.0\n# ssh -p 2222 -X root@<docker host> /opt/Meshroom_bundle/Meshroom # Password is 'meshroom'\n\nENV MESHROOM_DEV=/opt/Meshroom \\\n    MESHROOM_BUILD=/tmp/Meshroom_build \\\n    MESHROOM_BUNDLE=/opt/Meshroom_bundle \\\n    AV_INSTALL=/opt/AliceVision_install \\\n    QT_DIR=/opt/Qt/6.8.3/gcc_64 \\\n    PATH=\"${PATH}:${MESHROOM_BUNDLE}\"\n\nCOPY *.txt *.md *.py ${MESHROOM_DEV}/\nCOPY ./docs ${MESHROOM_DEV}/docs\nCOPY ./meshroom ${MESHROOM_DEV}/meshroom\nCOPY ./tests ${MESHROOM_DEV}/tests\nCOPY ./bin ${MESHROOM_DEV}/bin\n\nWORKDIR ${MESHROOM_DEV}\n\nRUN python setup.py install_exe -d \"${MESHROOM_BUNDLE}\" && \\\n        find ${MESHROOM_BUNDLE} -name \"*Qt6Web*\" -delete && \\\n        find ${MESHROOM_BUNDLE} -name \"*Qt6Designer*\" -delete && \\\n        rm -rf ${MESHROOM_BUNDLE}/lib/PySide6/typesystems/ \\\n                ${MESHROOM_BUNDLE}/lib/PySide6/examples/ \\\n                ${MESHROOM_BUNDLE}/lib/PySide6/include/ \\\n                ${MESHROOM_BUNDLE}/lib/PySide6/Qt/translations/ \\\n                ${MESHROOM_BUNDLE}/lib/PySide6/Qt/resources/ \\\n                ${MESHROOM_BUNDLE}/lib/PySide6/QtWeb* \\\n                ${MESHROOM_BUNDLE}/lib/PySide6/rcc \\\n                ${MESHROOM_BUNDLE}/lib/PySide6/designer\n\nWORKDIR ${MESHROOM_BUILD}\n\n# Move the bundled installation of AliceVision into Meshroom's bundle\nRUN mkdir ${MESHROOM_BUNDLE}/aliceVision && \\\n    mv /opt/AliceVision_bundle/* ${MESHROOM_BUNDLE}/aliceVision\n\n# Build Meshroom plugins\nRUN cmake \"${MESHROOM_DEV}\" -DALICEVISION_ROOT=\"${AV_INSTALL}\" -DCMAKE_INSTALL_PREFIX=\"${MESHROOM_BUNDLE}/qtPlugins\"\nRUN make \"-j$(nproc)\" QtAliceVision\nRUN make \"-j$(nproc)\" && \\\n\trm -rf \"${MESHROOM_BUILD}\" \"${MESHROOM_DEV}\" \\\n\t\t${MESHROOM_BUNDLE}/aliceVision/share/doc \\\n\t\t${MESHROOM_BUNDLE}/aliceVision/share/eigen3 \\\n\t\t${MESHROOM_BUNDLE}/aliceVision/share/fonts \\\n\t\t${MESHROOM_BUNDLE}/aliceVision/share/lemon \\\n\t\t${MESHROOM_BUNDLE}/aliceVision/share/libraw \\\n\t\t${MESHROOM_BUNDLE}/aliceVision/share/man/ \\\n\t\taliceVision/share/pkgconfig\n\n# PySide6: copy missing libQt63DQuickScene3D.so along with its dependencies to avoid runtime issues\nRUN cp ${QT_DIR}/lib/libQt63DQuickScene3D.so.6.8.3 ${MESHROOM_BUNDLE}/lib/PySide6/Qt/qml/QtQuick/Dialogs && \\\n    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 && \\\n    cp ${MESHROOM_BUNDLE}/lib/PySide6/Qt/lib/libQt6Concurrent.so.6 ${MESHROOM_BUNDLE}/lib/PySide6/Qt/qml/QtQuick/Dialogs && \\\n    cp ${MESHROOM_BUNDLE}/lib/PySide6/Qt/qml/Qt3D/Animation/libQt63DAnimation.so.6 ${MESHROOM_BUNDLE}/lib/PySide6/Qt/qml/QtQuick/Dialogs && \\\n    cp ${MESHROOM_BUNDLE}/lib/PySide6/Qt/qml/Qt3D/Core/libQt63DCore.so.6 ${MESHROOM_BUNDLE}/lib/PySide6/Qt/qml/QtQuick/Dialogs && \\\n    cp ${MESHROOM_BUNDLE}/lib/PySide6/Qt/qml/Qt3D/Input/libQt63DInput.so.6 ${MESHROOM_BUNDLE}/lib/PySide6/Qt/qml/QtQuick/Dialogs && \\\n    cp ${MESHROOM_BUNDLE}/lib/PySide6/Qt/qml/Qt3D/Logic/libQt63DLogic.so.6 ${MESHROOM_BUNDLE}/lib/PySide6/Qt/qml/QtQuick/Dialogs && \\\n    cp ${MESHROOM_BUNDLE}/lib/PySide6/Qt/qml/Qt3D/Render/libQt63DRender.so.6 ${MESHROOM_BUNDLE}/lib/PySide6/Qt/qml/QtQuick/Dialogs\n\n# Enable SSH X11 forwarding, needed when the Docker image\n# is run on a remote machine\nRUN apt install ssh xauth && \\\n\tsystemctl enable ssh && \\\n\tmkdir -p /run/sshd\n\nRUN sed -i \"s/^.*X11Forwarding.*$/X11Forwarding yes/; s/^.*X11UseLocalhost.*$/X11UseLocalhost no/; s/^.*PermitRootLogin prohibit-password/PermitRootLogin yes/; s/^.*X11UseLocalhost.*/X11UseLocalhost no/;\" /etc/ssh/sshd_config\nRUN echo \"root:meshroom\" | chpasswd\n\nWORKDIR /root\n\nEXPOSE 22\nCMD [\"/usr/sbin/sshd\", \"-D\"]\n"
  },
  {
    "path": "docker/Dockerfile_ubuntu_deps",
    "content": "ARG AV_VERSION\nARG CUDA_VERSION\nARG UBUNTU_VERSION\nFROM alicevision/alicevision:${AV_VERSION}-ubuntu${UBUNTU_VERSION}-cuda${CUDA_VERSION}\nLABEL maintainer=\"AliceVision Team alicevision-team@googlegroups.com\"\n\n# Execute with nvidia docker (https://github.com/nvidia/nvidia-docker/wiki/Installation-(version-2.0))\n# docker run -it --runtime=nvidia meshroom\n\nENV MESHROOM_DEV=/opt/Meshroom \\\n    MESHROOM_BUILD=/tmp/Meshroom_build \\\n    QT_DIR=/opt/Qt/6.8.3/gcc_64 \\\n    QT_CI_LOGIN=alicevisionjunk@gmail.com \\\n    QT_CI_P=azerty1.\n\n# Install libs needed by Qt\nRUN apt-get update && \\\n\tDEBIAN_FRONTEND=noninteractive apt-get install -yq --no-install-recommends \\\n        flex \\\n        fontconfig \\\n        libfreetype6 \\\n        libglib2.0-0 \\ \n        libice6 \\\n        libx11-6 \\\n        libxcb1 \\\n        libxext6 \\\n        libxi6 \\\n        libxrender1 \\\n        libsm6\n\nRUN apt-get update && \\\n    DEBIAN_FRONTEND=noninteractive apt-get install -yq --no-install-recommends \\\n        libxt-dev \\\n        libosmesa-dev \\\n        libgl-dev \\\n        libegl-dev \\\n        libglu-dev \\\n        libxkbcommon-x11-0 \\\n        libz-dev \\\n        systemd \\\n        ssh\n\nRUN apt-get update && \\\n    DEBIAN_FRONTEND=noninteractive apt-get install -yq --no-install-recommends \\\n        libxcb1-dev \\\n        libxcb-icccm4 \\\n        libxcb-render-util0 \\\n        libxcb-shape0 \\\n        libxcb-keysyms1 \\\n        libxcb-image0 \\\n        libxkbcommon-dev\n\nRUN apt-get install -y --no-install-recommends \\\n\tsoftware-properties-common\n\n# Install Python3\n# RUN apt install python3-pip -y && pip3 install --upgrade pip\n\n# Install Qt (to build plugins)\nWORKDIR /tmp/qt\nCOPY dl/qt.run /tmp/qt\nRUN chmod +x qt.run\nRUN ./qt.run --root /opt/Qt --verbose --email ${QT_CI_LOGIN} --password ${QT_CI_P} --accept-obligations \\\n    --accept-licenses --default-answer --platform minimal --auto-answer installationErrorWithCancel=Ignore \\\n    --no-force-installations --no-default-installations --confirm-command \\\n    install qt.qt6.683.linux_gcc_64 qt.qt6.683.addons.qtcharts qt.qt6.683.addons.qt3d\nRUN rm qt.run\n\n# Strip sections containing \".note.ABI.tag\" from .so: https://github.com/Microsoft/WSL/issues/3023\nRUN find ${QT_DIR}/lib/ -name '*.so' | xargs strip --remove-section=.note.ABI-tag\n\nCOPY ./*requirements.txt ./setup.py ${MESHROOM_DEV}/\n\n# Install Meshroom requirements and freeze bundle\nWORKDIR \"${MESHROOM_DEV}\"\nRUN python -m pip install -r dev_requirements.txt -r requirements.txt\n"
  },
  {
    "path": "docker/build-all.sh",
    "content": "#!/bin/sh\n\nset -e\n\ntest -d docker || (\n        echo This script must be run from the top level Meshroom directory\n\texit 1\n)\n\nCUDA_VERSION=12.1.1 UBUNTU_VERSION=22.04 docker/build-ubuntu.sh\n\nCUDA_VERSION=12.1.1 ROCKY_VERSION=9 docker/build-rocky.sh"
  },
  {
    "path": "docker/build-rocky.sh",
    "content": "#!/bin/bash\nset -e\n\ntest -z \"$MESHROOM_VERSION\" && MESHROOM_VERSION=\"$(git rev-parse --abbrev-ref HEAD)-$(git rev-parse --short HEAD)\"\ntest -z \"$AV_VERSION\" && echo \"AliceVision version not specified, set AV_VERSION in the environment\" && exit 1\ntest -z \"$CUDA_VERSION\" && CUDA_VERSION=12.1.1\ntest -z \"$ROCKY_VERSION\" && ROCKY_VERSION=9\n\ntest -d docker || (\n    echo This script must be run from the top level Meshroom directory\n    exit 1\n)\n\ntest -d dl || \\\n    mkdir dl\ntest -f dl/qt.run || \\\n    wget --no-check-certificate \"https://download.qt.io/official_releases/online_installers/qt-online-installer-linux-x64-online.run\" -O \"dl/qt.run\"\n\n# DEPENDENCIES\ndocker build \\\n    --rm \\\n    --progress=plain \\\n    --build-arg \"CUDA_VERSION=${CUDA_VERSION}\" \\\n    --build-arg \"ROCKY_VERSION=${ROCKY_VERSION}\" \\\n    --build-arg \"AV_VERSION=${AV_VERSION}\" \\\n    --tag \"alicevision/meshroom-deps:${MESHROOM_VERSION}-av${AV_VERSION}-rocky${ROCKY_VERSION}-cuda${CUDA_VERSION}\" \\\n    -f docker/Dockerfile_rocky_deps .\n\n# Meshroom\ndocker build \\\n    --rm \\\n    --progress=plain \\\n    --build-arg \"MESHROOM_VERSION=${MESHROOM_VERSION}\" \\\n    --build-arg \"CUDA_VERSION=${CUDA_VERSION}\" \\\n    --build-arg \"ROCKY_VERSION=${ROCKY_VERSION}\" \\\n    --build-arg \"AV_VERSION=${AV_VERSION}\" \\\n    --tag \"alicevision/meshroom:${MESHROOM_VERSION}-av${AV_VERSION}-rocky${ROCKY_VERSION}-cuda${CUDA_VERSION}\" \\\n    -f docker/Dockerfile_rocky .\n\n"
  },
  {
    "path": "docker/build-ubuntu.sh",
    "content": "#!/bin/bash\nset -e\n\ntest -z \"$MESHROOM_VERSION\" && MESHROOM_VERSION=\"$(git rev-parse --abbrev-ref HEAD)-$(git rev-parse --short HEAD)\"\ntest -z \"$AV_VERSION\" && echo \"AliceVision version not specified, set AV_VERSION in the environment\" && exit 1\ntest -z \"$CUDA_VERSION\" && CUDA_VERSION=12.1.1\ntest -z \"$UBUNTU_VERSION\" && UBUNTU_VERSION=22.04\n\ntest -d docker || (\n    echo This script must be run from the top level Meshroom directory\n    exit 1\n)\n\ntest -d dl || \\\n    mkdir dl\ntest -f dl/qt.run || \\\n    wget --no-check-certificate \"https://download.qt.io/official_releases/online_installers/qt-online-installer-linux-x64-online.run\" -O \"dl/qt.run\"\n\n# DEPENDENCIES\ndocker build \\\n    --rm \\\n    --progress=plain \\\n    --build-arg \"CUDA_VERSION=${CUDA_VERSION}\" \\\n    --build-arg \"UBUNTU_VERSION=${UBUNTU_VERSION}\" \\\n    --build-arg \"AV_VERSION=${AV_VERSION}\" \\\n    --tag \"alicevision/meshroom-deps:${MESHROOM_VERSION}-av${AV_VERSION}-ubuntu${UBUNTU_VERSION}-cuda${CUDA_VERSION}\" \\\n    -f docker/Dockerfile_ubuntu_deps .\n\n# Meshroom\ndocker build \\\n    --rm \\\n    --progress=plain \\\n    --build-arg \"MESHROOM_VERSION=${MESHROOM_VERSION}\" \\\n    --build-arg \"CUDA_VERSION=${CUDA_VERSION}\" \\\n    --build-arg \"UBUNTU_VERSION=${UBUNTU_VERSION}\" \\\n    --build-arg \"AV_VERSION=${AV_VERSION}\" \\\n    --tag \"alicevision/meshroom:${MESHROOM_VERSION}-av${AV_VERSION}-ubuntu${UBUNTU_VERSION}-cuda${CUDA_VERSION}\" \\\n    -f docker/Dockerfile_ubuntu .\n\n"
  },
  {
    "path": "docker/extract-rocky.sh",
    "content": "#!/bin/bash\nset -ex\n\ntest -z \"$MESHROOM_VERSION\" && MESHROOM_VERSION=\"$(git rev-parse --abbrev-ref HEAD)-$(git rev-parse --short HEAD)\"\ntest -z \"$AV_VERSION\" && echo \"AliceVision version not specified, set AV_VERSION in the environment\" && exit 1\ntest -z \"$CUDA_VERSION\" && CUDA_VERSION=\"12.1.1\"\ntest -z \"$ROCKY_VERSION\" && ROCKY_VERSION=\"9\"\n\ntest -d docker || (\n\techo This script must be run from the top level Meshroom directory\n\texit 1\n)\n\nVERSION_NAME=${MESHROOM_VERSION}-av${AV_VERSION}-rocky${ROCKY_VERSION}-cuda${CUDA_VERSION}\n\n# Retrieve the Meshroom bundle folder\nrm -rf ./Meshroom-${VERSION_NAME}\nCID=$(docker create alicevision/meshroom:${VERSION_NAME})\ndocker cp ${CID}:/opt/Meshroom_bundle ./Meshroom-${VERSION_NAME}\ndocker rm ${CID}\n\n"
  },
  {
    "path": "docker/extract-ubuntu.sh",
    "content": "#!/bin/bash\nset -ex\n\ntest -z \"$MESHROOM_VERSION\" && MESHROOM_VERSION=\"$(git rev-parse --abbrev-ref HEAD)-$(git rev-parse --short HEAD)\"\ntest -z \"$AV_VERSION\" && echo \"AliceVision version not specified, set AV_VERSION in the environment\" && exit 1\ntest -z \"$CUDA_VERSION\" && CUDA_VERSION=\"12.1.1\"\ntest -z \"$UBUNTU_VERSION\" && UBUNTU_VERSION=\"22.04\"\n\ntest -d docker || (\n\techo This script must be run from the top level Meshroom directory\n\texit 1\n)\n\nVERSION_NAME=${MESHROOM_VERSION}-av${AV_VERSION}-ubuntu${UBUNTU_VERSION}-cuda${CUDA_VERSION}\n\n# Retrieve the Meshroom bundle folder\nrm -rf ./Meshroom-${VERSION_NAME}\nCID=$(docker create alicevision/meshroom:${VERSION_NAME})\ndocker cp ${CID}:/opt/Meshroom_bundle ./Meshroom-${VERSION_NAME}\ndocker rm ${CID}\n\n"
  },
  {
    "path": "docs/.gitignore",
    "content": "# Sphinx\nbuild/\nsource/generated/\n"
  },
  {
    "path": "docs/Makefile",
    "content": "# Minimal makefile for Sphinx documentation\n#\n\n# You can set these variables from the command line, and also\n# from the environment for the first two.\nSPHINXOPTS    ?=\nSPHINXBUILD   ?= sphinx-build\nSOURCEDIR     = source\nBUILDDIR      = build\n\n# Put it first so that \"make\" without argument is like \"make help\".\nhelp:\n\t@$(SPHINXBUILD) -M help \"$(SOURCEDIR)\" \"$(BUILDDIR)\" $(SPHINXOPTS) $(O)\n\n.PHONY: help Makefile\n\n# Catch-all target: route all unknown targets to Sphinx using the new\n# \"make mode\" option.  $(O) is meant as a shortcut for $(SPHINXOPTS).\n%: Makefile\n\t@$(SPHINXBUILD) -M $@ \"$(SOURCEDIR)\" \"$(BUILDDIR)\" $(SPHINXOPTS) $(O)\n"
  },
  {
    "path": "docs/README.md",
    "content": "# Documentation\n\nWe use [Sphinx](https://www.sphinx-doc.org) to generate Meshroom's documentation.\n\n## Requirements\n\nTo install all the requirements for building the documentation, simply run: \n```bash\npip install sphinx sphinx-rtd-theme myst-parser\n```\n\nYou also need to have [Graphviz](https://graphviz.org/) installed.\n\n> Note: since Sphinx will import the entire `meshroom` package, all requirements for Meshroom must also be installed\n\n## Build\n\nTo generate the documentation, go to the `docs` folder and run the Sphinx makefile: \n```bash\ncd meshroom/docs\nmake html\n```\n\nTo access the documentation, simply go to `meshroom/docs/build/html` and open `index.html` in a browser.\n"
  },
  {
    "path": "docs/make.bat",
    "content": "@ECHO OFF\n\npushd %~dp0\n\nREM Command file for Sphinx documentation\n\nif \"%SPHINXBUILD%\" == \"\" (\n\tset SPHINXBUILD=sphinx-build\n)\nset SOURCEDIR=source\nset BUILDDIR=build\n\n%SPHINXBUILD% >NUL 2>NUL\nif errorlevel 9009 (\n\techo.\n\techo.The 'sphinx-build' command was not found. Make sure you have Sphinx\n\techo.installed, then set the SPHINXBUILD environment variable to point\n\techo.to the full path of the 'sphinx-build' executable. Alternatively you\n\techo.may add the Sphinx directory to PATH.\n\techo.\n\techo.If you don't have Sphinx installed, grab it from\n\techo.https://www.sphinx-doc.org/\n\texit /b 1\n)\n\nif \"%1\" == \"\" goto help\n\n%SPHINXBUILD% -M %1 %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O%\ngoto end\n\n:help\n%SPHINXBUILD% -M help %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O%\n\n:end\npopd\n"
  },
  {
    "path": "docs/requirements.txt",
    "content": "myst-parser\n"
  },
  {
    "path": "docs/source/_ext/__init__.py",
    "content": ""
  },
  {
    "path": "docs/source/_ext/fetch_md.py",
    "content": "# Sphinx extension defining the fetch_md directive\n#\n# Goal:\n# include the content of a given markdown file\n#\n# Usage:\n# .. fetch_md:: path/to/file.md\n# the filepath is relative to the project base directory\n#\n# Note:\n# some markdown files contain links to other files that belong to the project\n# since those links are relative to the file location, they are no longer valid in the new context\n# therefore we try to update these links but it is not always possible\n\nimport os\nfrom docutils.nodes import SparseNodeVisitor\nfrom docutils.parsers.rst import Directive\nfrom utils import md_to_docutils, get_link_key\n\n\nclass Relinker(SparseNodeVisitor):\n\n    def relink(self, node, base_dir):\n        key = get_link_key(node)\n        if key is None:\n            return\n        link = node.attributes[key]\n        if link.startswith('http') or link.startswith('mailto'):\n            return\n        if link.startswith('/'):\n            link = link[1:]\n        node.attributes[key] = base_dir+'/'+link\n\n    def visit_image(self, node):\n        self.relink(node, os.getenv('PROJECT_DIR'))\n\n\nclass FetchMd(Directive):\n\n    required_arguments = 1\n\n    def run(self):\n        path = os.path.abspath(os.getenv('PROJECT_DIR')+'/'+self.arguments[0])\n        result = []\n        try:\n            with open(path) as file:\n                text = file.read()\n                doc = md_to_docutils(text)\n                relinker = Relinker(doc)\n                doc.walk(relinker)\n                result.append(doc[0])\n        except FileNotFoundError:\n            pass\n        return result\n\n\ndef setup(app):\n    app.add_directive('fetch_md', FetchMd)\n\n    return {\n        'version': '0.1',\n        'parallel_read_safe': True,\n        'parallel_write_safe': True\n    }\n"
  },
  {
    "path": "docs/source/_ext/meshroom_doc.py",
    "content": "# Sphinx extension defining the meshroom_doc directive\n#\n# Goal:\n# create specific documentation content for meshroom objects\n#\n# Usage:\n# .. meshroom_doc::\n#    :module: module_name\n#    :class: class_name\n#\n# Note:\n# for now this tool focuses only on meshroom nodes\n\nfrom docutils.parsers.rst import Directive\nfrom utils import md_to_docutils\n\nimport importlib\nfrom meshroom.core import desc\n\n\nclass MeshroomDoc(Directive):\n\n    required_arguments = 4\n\n    def parse_args(self):\n        module_name = self.arguments[self.arguments.index(':module:')+1]\n        class_name = self.arguments[self.arguments.index(':class:')+1]\n        return (module_name, class_name)\n\n    def run(self):\n        result = []\n        # Import module and class\n        module_name, class_name = self.parse_args()\n        module = importlib.import_module(module_name)\n        node_class = getattr(module, class_name)\n        # Class inherits desc.Node\n        if issubclass(node_class, desc.Node):\n            node = node_class()\n            # Category\n            doc = md_to_docutils('**Category**: {}'.format(node.category))\n            result.extend(doc.children)\n            # Documentation\n            doc = md_to_docutils(node.documentation)\n            result.extend(doc.children)\n            # Inputs\n            text_inputs = '**Inputs**: \\n'\n            for attr in node.inputs:\n                text_inputs += '- {} ({})\\n'.format(attr._name, attr.__class__.__name__)\n            doc = md_to_docutils(text_inputs)\n            result.extend(doc.children)\n            # Outputs\n            text_outputs = '**Outputs**: \\n'\n            for attr in node.outputs:\n                text_outputs += '- {} ({})\\n'.format(attr._name, attr.__class__.__name__)\n            doc = md_to_docutils(text_outputs)\n            result.extend(doc.children)\n        return result\n\n\ndef setup(app):\n    app.add_directive(\"meshroom_doc\", MeshroomDoc)\n    return {\n        'version': '0.1',\n        'parallel_read_safe': True,\n        'parallel_write_safe': True,\n    }\n"
  },
  {
    "path": "docs/source/_ext/utils.py",
    "content": "# Utility functions for custom Sphinx extensions\n\nfrom myst_parser.docutils_ import Parser\nfrom myst_parser.mdit_to_docutils.base import make_document\n\n\n# Given a string written in markdown\n# parse its content\n# and return the corresponding docutils document\ndef md_to_docutils(text):\n    parser = Parser()\n    doc = make_document(parser_cls=Parser)\n    parser.parse(text, doc)\n    return doc\n\n\n# Given a docutils node\n# find an attribute that corresponds to a link (if it exists)\n# and return its key\ndef get_link_key(node):\n    link_keys = ['uri', 'refuri', 'refname']\n    for key in link_keys:\n        if key in node.attributes.keys():\n            return key\n    return None\n"
  },
  {
    "path": "docs/source/_templates/autosummary/class.rst",
    "content": "{{ fullname | escape | underline}}\n\n\n.. inheritance-diagram:: {{ fullname }}\n\n\n.. meshroom_doc::\n   :module: {{ module }}\n   :class: {{ objname }}\n\n\n.. currentmodule:: {{ module }}\n\n.. autoclass:: {{ objname }}\n\n   {% block methods %}\n   .. automethod:: __init__\n\n   {% if methods %}\n   .. rubric:: {{ _('Methods') }}\n\n   .. autosummary::\n   {% for item in methods %}\n      ~{{ name }}.{{ item }}\n   {%- endfor %}\n   {% endif %}\n   {% endblock %}\n\n   {% block attributes %}\n   {% if attributes %}\n   .. rubric:: {{ _('Attributes') }}\n\n   .. autosummary::\n   {% for item in attributes %}\n      ~{{ name }}.{{ item }}\n   {%- endfor %}\n   {% endif %}\n   {% endblock %}\n"
  },
  {
    "path": "docs/source/_templates/autosummary/module.rst",
    "content": "{{ fullname | escape | underline}}\n\n\n.. automodule:: {{ fullname }}\n\n   {% block attributes %}\n   {% if attributes %}\n   .. rubric:: {{ _('Module Attributes') }}\n\n   .. autosummary::\n   {% for item in attributes %}\n      {{ item }}\n   {%- endfor %}\n   {% endif %}\n   {% endblock %}\n\n   {% block functions %}\n   {% if functions %}\n   .. rubric:: {{ _('Functions') }}\n\n   .. autosummary::\n   {% for item in functions %}\n      {{ item }}\n   {%- endfor %}\n   {% endif %}\n   {% endblock %}\n\n   {% block classes %}\n   {% if classes %}\n   .. rubric:: {{ _('Classes') }}\n\n   .. autosummary::\n      :toctree:\n      :recursive:\n   {% for item in classes %}\n      {{ item }}\n   {%- endfor %}\n   {% endif %}\n   {% endblock %}\n\n   {% block exceptions %}\n   {% if exceptions %}\n   .. rubric:: {{ _('Exceptions') }}\n\n   .. autosummary::\n   {% for item in exceptions %}\n      {{ item }}\n   {%- endfor %}\n   {% endif %}\n   {% endblock %}\n\n{% block modules %}\n{% if modules %}\n.. rubric:: Modules\n\n.. autosummary::\n   :toctree:\n   :recursive:\n{% for item in modules %}\n   {{ item }}\n{%- endfor %}\n{% endif %}\n{% endblock %}\n"
  },
  {
    "path": "docs/source/api.rst",
    "content": "Python API Reference\n====================\n\n\n.. autosummary::\n   :recursive:\n   :toctree: generated\n\n   meshroom\n   tests\n"
  },
  {
    "path": "docs/source/changes.rst",
    "content": "Release Notes\n=============\n\n\n.. fetch_md:: CHANGES.md\n"
  },
  {
    "path": "docs/source/conf.py",
    "content": "# Configuration file for the Sphinx documentation builder.\n#\n# For the full list of built-in configuration values, see the documentation:\n# https://www.sphinx-doc.org/en/master/usage/configuration.html\n\n# -- Project information -----------------------------------------------------\n# https://www.sphinx-doc.org/en/master/usage/configuration.html#project-information\n\nimport os\nfrom pathlib import Path\nimport sys\n\nos.environ['PROJECT_DIR'] = Path('../..').resolve().as_posix()\n\nsys.path.append(os.path.abspath(os.getenv('PROJECT_DIR')))\nsys.path.append(os.path.abspath('./_ext'))\n\nproject = 'Meshroom'\ncopyright = '2025, AliceVision Association'\nauthor = 'AliceVision Association'\n\n# -- General configuration ---------------------------------------------------\n# https://www.sphinx-doc.org/en/master/usage/configuration.html#general-configuration\n\nextensions = [\n    'sphinx.ext.autodoc',\n    'sphinx.ext.autosummary',\n    'fetch_md',\n    'meshroom_doc',\n    'sphinx.ext.graphviz',\n    'sphinx.ext.inheritance_diagram'\n]\n\ntemplates_path = ['_templates']\nexclude_patterns = []\n\n\n# -- Options for HTML output -------------------------------------------------\n# https://www.sphinx-doc.org/en/master/usage/configuration.html#options-for-html-output\n\nhtml_theme = 'sphinx_rtd_theme'\nhtml_static_path = ['_static']\n"
  },
  {
    "path": "docs/source/index.rst",
    "content": "Welcome to meshroom's documentation!\n====================================\n\n\n.. toctree::\n   :maxdepth: 2\n   :caption: Contents:\n\n   api\n   install\n   changes\n\n\n.. fetch_md:: README.md\n"
  },
  {
    "path": "docs/source/install.rst",
    "content": "Install\n=======\n\n\n.. fetch_md:: INSTALL.md\n"
  },
  {
    "path": "localfarm/__init__.py",
    "content": ""
  },
  {
    "path": "localfarm/localFarm.py",
    "content": "#!/usr/bin/env python\n\n\"\"\"\nLocal Farm : A simple local job runner\n\"\"\"\n\nfrom __future__ import annotations  # For forward references in type hints\n\nimport logging\nimport json\nimport socket\nimport uuid\nfrom collections import defaultdict\nfrom pathlib import Path\nfrom typing import Dict, List, Generator\n\nlogging.basicConfig(\n    level=logging.INFO,\n    format='%(asctime)s [%(name)s][%(levelname)s] %(message)s'\n)\nlogger = logging.getLogger(\"LocalFarm\")\nlogger.setLevel(logging.INFO)\n\n\nclass LocalFarmEngine:\n    \"\"\" Client to communicate with the farm backend. \"\"\"\n\n    def __init__(self, root):\n        self.root = Path(root)\n        self.tcpPortFile = self.root / \"backend.port\"\n\n    def connect(self):\n        \"\"\" Connect to the backend. \"\"\"\n        print(\"Connect to farm located at\", self.root)\n        if self.tcpPortFile.exists():\n            try:\n                port = int(self.tcpPortFile.read_text())\n                sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)\n                sock.connect((\"localhost\", port))\n                return sock\n            except Exception as e:\n                logger.error(f\"Could not connect via TCP: {e}\")\n                raise ConnectionError(\"Cannot connect to farm backend\")\n        raise ConnectionError(\"Farm backend not found\")\n\n    def _call(self, method, **params):\n        \"\"\" Make an query to the backend. \"\"\"\n        request = {\n            \"method\": method,\n            \"params\": params\n        }\n        sock = self.connect()\n        try:\n            # Send request\n            request_data = json.dumps(request) + \"\\n\"\n            sock.sendall(request_data.encode(\"utf-8\"))\n            # Receive response\n            response_data = b\"\"\n            while True:\n                chunk = sock.recv(4096)\n                if not chunk:\n                    break\n                response_data += chunk\n                if b\"\\n\" in chunk:\n                    break\n            response = json.loads(response_data.decode(\"utf-8\"))\n            if not response.get(\"success\"):\n                raise RuntimeError(response.get(\"error\", \"Unknown error\"))\n            return response\n        finally:\n            sock.close()\n\n    def submit_job(self, job: Job):\n        \"\"\" Submit the job to the farm. \"\"\"\n        # Create the job\n        createdJob = self._call(\"create_job\", name=job.name)\n        jid = createdJob[\"jid\"]\n        # Create the tasks\n        tasksCreated = {}\n        for task in job.tasksDFS():\n            parentTasks = job.getTaskDependencies(task)\n            deps = []\n            for parentTask in parentTasks:\n                if parentTask not in tasksCreated:\n                    raise RuntimeError(f\"Parent task {parentTask.name} not created yet\")\n                deps.append(tasksCreated[parentTask])\n            createdTask = self._call(\"create_task\",\n                jid=jid, name=task.name, command=task.command,\n                metadata=task.metadata, dependencies=deps, env=task.env)\n            tasksCreated[task] = createdTask[\"tid\"]\n        # Submit the job\n        self._call(\"submit_job\", jid=jid)\n        return {\"jid\": jid}\n\n    def create_additional_task(self, jid, tid, task):\n        \"\"\" Create new task in an existing job. \"\"\"\n        createdTask = self._call(\"expand_task\",\n            jid=jid, name=task.name, command=task.command,\n            metadata=task.metadata, parentTid=tid, env=task.env)\n        return {\"tid\": createdTask[\"tid\"]}\n\n    def get_job_info(self, jid):\n        \"\"\" Get job status. \"\"\"\n        return self._call(\"get_job_info\", jid=jid)[\"result\"]\n\n    def pause_job(self, jid):\n        \"\"\" Pause a job. \"\"\"\n        return self._call(\"pause_job\", jid=jid)\n\n    def unpause_job(self, jid):\n        \"\"\" Resume a job. \"\"\"\n        return self._call(\"unpause_job\", jid=jid)\n\n    def interrupt_job(self, jid):\n        \"\"\" Interrupt a job. \"\"\"\n        return self._call(\"interrupt_job\", jid=jid)\n\n    def restart_job(self, jid):\n        \"\"\" Restart a job. \"\"\"\n        return self._call(\"restart_job\", jid=jid)\n\n    def restart_error_tasks(self, jid):\n        \"\"\" Restart error tasks. \"\"\"\n        return self._call(\"restart_error_tasks\", jid=jid)\n\n    def stop_task(self, jid, tid):\n        \"\"\" Stop a specific task. \"\"\"\n        return self._call(\"stop_task\", jid=jid, tid=tid)\n\n    def skip_task(self, jid, tid):\n        \"\"\" Stop a specific task. \"\"\"\n        return self._call(\"skip_task\", jid=jid, tid=tid)\n\n    def restart_task(self, jid, tid):\n        \"\"\" Restart a task. \"\"\"\n        return self._call(\"restart_task\", jid=jid, tid=tid)\n\n    def list_jobs(self) -> list:\n        \"\"\" List all jobs. \"\"\"\n        return self._call(\"list_jobs\")[\"jobs\"]\n\n    def get_job_status(self, jid: int) -> dict:\n        for job in self.list_jobs():\n            if job[\"jid\"] == jid:\n                return job\n        return {}\n\n    def get_job_errors(self, jid: int) -> str:\n        \"\"\" Get job error logs. \"\"\"\n        return self._call(\"get_job_errors\", jid=jid)[\"result\"]\n\n    def ping(self):\n        \"\"\" Check if backend is alive. \"\"\"\n        try:\n            self.connect().close()\n            return True\n        except Exception:\n            return False\n\n\nclass Task:\n    def __init__(self, name, command, metadata=None, env=None):\n        self.uid = str(uuid.uuid1())\n        self.name = name\n        self.command = command\n        self.metadata = metadata or {}\n        self.env = env or {}\n\n    def __repr__(self):\n        return f\"<Task {self.name}|{self.uid}>\"\n\n    def __hash__(self):\n        return hash(self.uid)\n\n\nclass Job:\n    def __init__(self, name):\n        self.name = name\n        self.tasks: Dict[str, Task] = {}\n        self.dependencies: Dict[str: List[str]] = defaultdict(set)\n        self.reverseDependencies: Dict[str: List[str]] = defaultdict(set)\n        self._engine: LocalFarmEngine = None\n\n    def setEngine(self, engine: LocalFarmEngine):\n        self._engine = engine\n\n    def addTask(self, task):\n        if task.name in self.tasks:\n            raise ValueError(f\"Task {task} already exists in job\")\n        self.tasks[task.uid] = task\n\n    def addTaskDependency(self, task: Task, dependsOn: Task):\n        if task.uid not in self.tasks:\n            raise ValueError(f\"Task {task} not found in job\")\n        if dependsOn.uid not in self.tasks:\n            raise ValueError(f\"Task {dependsOn} not found in job\")\n        self.dependencies[task.uid].add(dependsOn.uid)\n        self.reverseDependencies[dependsOn.uid].add(task.uid)\n        if self.hasCycle():\n            # Rollback\n            self.dependencies[task.uid].remove(dependsOn.uid)\n            self.reverseDependencies[dependsOn.uid].remove(task.uid)\n            raise ValueError(\"Adding this task creates a cycle in the job dependencies\")\n\n    def getTaskDependencies(self, task):\n        return [self.tasks[depUid] for depUid in self.dependencies.get(task.uid, [])]\n\n    def getRootTasks(self) -> List[Task]:\n        roots = []\n        for taskUid, task in self.tasks.items():\n            if not self.dependencies.get(taskUid):\n                roots.append(task)\n        return roots\n\n    def hasCycle(self) -> bool:\n        \"\"\" Check there are no cycles in the task graph. \"\"\"\n        def exploreTask(taskUid, taskParents=None):\n            taskParents = taskParents or set()\n            if taskUid in taskParents:\n                return True\n            childrenParents = taskParents.copy()\n            childrenParents.add(taskUid)\n            for childUid in self.reverseDependencies[taskUid]:\n                failed = exploreTask(childUid, childrenParents)\n                if failed:\n                    return True\n            return False\n        # Start from root and explore down\n        roots = self.getRootTasks()\n        if not roots:\n            return True\n        for task in roots:\n            failed = exploreTask(task.uid)\n            if failed:\n                return True\n        return False\n\n    def tasksDFS(self) -> Generator[Task]:\n        \"\"\"\n        Return tasks in topological order (dependencies before dependents).\n        Tasks closer to roots appear first.\n        \"\"\"\n        taskLevels = {}\n        def exploreTask(task: str, currentLevel=0):\n            if task in taskLevels:\n                if currentLevel > taskLevels[task]:\n                    taskLevels[task] = currentLevel\n            else:\n                taskLevels[task] = currentLevel\n            for child in self.reverseDependencies[task]:\n                exploreTask(child, currentLevel + 1)\n        # Start from root and explore down\n        for task in self.getRootTasks():\n            exploreTask(task.uid)\n        taskByLevel = defaultdict(list)\n        for taskUid, level in taskLevels.items():\n            taskByLevel[level].append(self.tasks[taskUid])\n        levels = sorted(list(taskByLevel.keys()))\n        for level in levels:\n            tasks = taskByLevel[level]\n            for task in tasks:\n                yield task\n\n    def submit(self, engine: LocalFarmEngine = None):\n        engine = engine or self._engine\n        if engine:\n            result = engine.submit_job(self)\n            return result\n        else:\n            raise ValueError(\"No LocalFarmEngine set for this job\")\n\n\ndef test():\n    #     _ B - D - F - G - H _\n    #    /         /     \\     \\\n    # A -         /       - I -- J\n    #    \\       /\n    #     - C - E - K - L - M\n    #                \\_____/\n    job = Job(\"job\")\n    for node in [\"F\", \"B\", \"K\", \"J\", \"A\", \"M\", \"L\", \"E\", \"C\", \"D\", \"G\", \"H\", \"I\"]:\n        job.addTask(Task(node, \"\"))\n\n    def addTaskDependencies(taskName, parentTaskName):\n        task = next(t for t in job.tasks.values() if t.name == taskName)\n        parentTask = next(t for t in job.tasks.values() if t.name == parentTaskName)\n        job.addTaskDependency(task, parentTask)\n\n    addTaskDependencies(\"B\", \"A\")\n    addTaskDependencies(\"C\", \"A\")\n    addTaskDependencies(\"D\", \"B\")\n    addTaskDependencies(\"E\", \"C\")\n    addTaskDependencies(\"F\", \"D\")\n    addTaskDependencies(\"C\", \"L\")\n    addTaskDependencies(\"F\", \"E\")\n    addTaskDependencies(\"K\", \"E\")\n    addTaskDependencies(\"M\", \"K\")\n    addTaskDependencies(\"G\", \"F\")\n    addTaskDependencies(\"H\", \"G\")\n    addTaskDependencies(\"I\", \"G\")\n    addTaskDependencies(\"J\", \"I\")\n    addTaskDependencies(\"J\", \"H\")\n\n    print(\"Tasks order : \", end=\"\")\n    for task in job.tasksDFS():\n        print(f\"{task.name} -> \", end=\"\")\n    print(\"END\")\n"
  },
  {
    "path": "localfarm/localFarmBackend.py",
    "content": "#!/usr/bin/env python\n\n\"\"\"\nLocal Farm : A simple local job runner\n\"\"\"\n\nimport os\nimport sys\nimport random\nimport argparse\nimport json\nimport shlex\nimport time\nimport signal\nimport logging\nimport subprocess\nfrom pathlib import Path\nfrom datetime import datetime\nfrom collections import defaultdict\nfrom typing import Union, Dict, List\nfrom enum import Enum\n# For the tcp server\nimport threading\nfrom socketserver import BaseRequestHandler, ThreadingTCPServer\n\nFARM_MAX_PARALLEL_TASKS = 10\nMAX_BYTES_REQUEST = 4096  # 8192 / 65536 if needed\n\nPathLike = Union[str, Path]\n\nlogging.basicConfig(\n    level=logging.INFO,\n    format='%(asctime)s [%(name)s][%(levelname)s] %(message)s'\n)\nlogger = logging.getLogger(\"LocalFarmBackend\")\nlogger.setLevel(logging.DEBUG)\n\n\nclass Status(Enum):\n    NONE = 0\n    SUBMITTED = 1\n    RUNNING = 2\n    ERROR = 3\n    STOPPED = 4\n    KILLED = 5\n    SUCCESS = 6\n    PAUSED = 7\n\n\nclass Task:\n    def __init__(self, jid: str, tid: str, label: str, command: str, metadata: dict, jobDir: PathLike, env: dict = None):\n        self.jid: str = jid\n        self.tid: str = tid\n        self.parentTids = []  # Tasks that must be completed before this one\n        self.childTids = []   # Task that depend on this one\n        self.label: str = label\n        self.command: str = command\n        self.metadata: dict = metadata or {}\n        self.env: dict = env or {}\n        self.taskDir: Path = Path(jobDir) / \"tasks\"\n        self.taskDir.mkdir(parents=True, exist_ok=True)\n        self.status: Status = Status.NONE\n        self.created_at = datetime.now()\n        self.started_at = None\n        self.finished_at = None\n        self.returnCode = None\n        self.process = None\n        self.logFile: Path = self.taskDir / f\"{tid}.log\"\n\n    def to_dict(self):\n        return {\n            \"jid\": self.jid,\n            \"tid\": self.tid,\n            \"label\": self.label,\n            \"command\": self.command,\n            \"metadata\": self.metadata,\n            \"env\": self.env,\n            \"status\": self.status.name,\n            \"created_at\": self.created_at.isoformat(),\n            \"started_at\": self.started_at.isoformat() if self.started_at else None,\n            \"finished_at\": self.finished_at.isoformat() if self.finished_at else None,\n            \"returnCode\": self.returnCode\n        }\n\n\nclass Job:\n    def __init__(self, jid: str, label: str, farmRoot: PathLike, maxParallel: int=4):\n        self.jid: str = jid\n        self.label: str = label\n        self.submitted: bool = False\n        self.jobDir: Path = Path(farmRoot) / \"jobs\" / str(jid)\n        self.jobDir.mkdir(parents=True, exist_ok=True)\n        self.lastJid = 0\n        self.status: Status = Status.NONE\n        self.created_at = datetime.now()\n        self.started_at = None\n        self.tasks: List[Task] = []\n        self.maxParallel: int = maxParallel\n        # Runtime tasks status\n        self.__stoppedTasks = []\n\n    def to_dict(self):\n        return {\n            \"jid\": self.jid,\n            \"label\": self.label,\n            \"submitted\": self.submitted,\n            \"status\": self.status.name,\n            \"created_at\": self.created_at.isoformat(),\n            \"started_at\": self.started_at.isoformat() if self.started_at else None,\n            \"tasks\": [t.to_dict() for t in self.tasks],\n            \"maxParallel\": self.maxParallel\n        }\n\n    @property\n    def errorLogs(self):\n        errorLog = \"\"\n        for task in self.tasks:\n            if task.status in (Status.ERROR, Status.STOPPED, Status.KILLED):\n                errorLog += f\"Task {task.tid} failed :\\n{task.logFile.read_text()}\\n\"\n        return errorLog\n\n    @property\n    def rootTasks(self):\n        return [t for t in self.tasks if len(t.parentTids) == 0]\n\n    def addTaskDependency(self, parentTask: Task, childTask: Task):\n        parentTask.childTids.append(childTask.tid)\n        childTask.parentTids.append(parentTask.tid)\n\n    def canStartTask(self, task: Task):\n        for parentTid in task.parentTids:\n            parentTask = next((t for t in self.tasks if t.tid == parentTid), None)\n            if parentTask and parentTask.status != Status.SUCCESS:\n                return False\n        return True\n\n    def getNextTaskToProcess(self):\n        # TODO : better to use the DFS implemented in localFarm.py\n        # Function to explore tasks\n        def exploreTask(task):\n            if task.status == Status.SUBMITTED:\n                return task\n            if task.status != Status.SUCCESS:\n                return None\n            children = [t for t in self.tasks if t.tid in task.childTids]\n            for taskCandidate in children:\n                submittedTask = exploreTask(taskCandidate)\n                if submittedTask:\n                    return submittedTask\n            return None\n        for task in self.rootTasks:\n            submittedTask = exploreTask(task)\n            if submittedTask:\n                return submittedTask\n        return None\n\n    def start(self):\n        self.status = Status.RUNNING\n        self.started_at = datetime.now()\n        for task in self.tasks:\n            task.status = Status.SUBMITTED\n\n    def updateStatusFromTasks(self):\n        for task in self.tasks:\n            if task.status in (Status.ERROR, Status.STOPPED, Status.KILLED):\n                self.status = Status.STOPPED\n                return\n            elif task.status == Status.RUNNING:\n                self.status = Status.RUNNING\n                return\n\n    def interrupt(self):\n        logger.info(f\"Interrupt job {self.jid}\")\n        self.status = Status.STOPPED\n        for task in self.tasks:\n            if task.status == Status.RUNNING and task.process:\n                logger.info(f\"Interrupt task {task.tid}\")\n                self.__stoppedTasks.append(task)\n                task.process.terminate()\n                task.status = Status.STOPPED\n        logger.info(f\"Job {self.jid} interrupted\")\n\n    def restart(self):\n        self.interrupt()\n        self.start()\n\n    def restartErrorTasks(self):\n        self.status = Status.RUNNING\n        for task in self.tasks:\n            if task.status in (Status.ERROR, Status.STOPPED, Status.KILLED):\n                task.status = Status.SUBMITTED\n\n    def resume(self):\n        logger.info(f\"Resume job {self.jid}\")\n        self.status = Status.RUNNING\n        for task in self.__stoppedTasks:\n            if task.status == Status.STOPPED:\n                task.status = Status.SUBMITTED\n        self.__stoppedTasks = []\n\n    def stopTask(self, tid):\n        for task in self.tasks:\n            if task.tid == tid:\n                if task.process and task.process.poll() is None:\n                    task.process.terminate()\n                task.status = Status.STOPPED\n                logger.info(f\"Task {tid} stopped\")\n                return True\n        return False\n\n    def skipTask(self, tid):\n        task = next((t for t in self.tasks if t.tid == tid), None)\n        if not task:\n            return False\n        task.status = Status.SUCCESS\n        if task.process and task.process.poll() is None:\n            task.process.terminate()\n        logger.info(f\"Task {tid} skipped\")\n        return True\n\n    def restartTask(self, tid):\n        for task in self.tasks:\n            if task.tid == tid:\n                if task.process and task.process.poll() is None:\n                    task.process.terminate()\n                task.status = Status.SUBMITTED\n                task.started_at = None\n                task.finished_at = None\n                task.return_code = None\n                task.process = None\n                logger.info(f\"Task {tid} rescheduled\")\n                return True\n        return False\n\n\nclass LocalFarmEngine:\n    def __init__(self, root: PathLike, maxParallel: int = FARM_MAX_PARALLEL_TASKS):\n        self.root: Path = Path(root)\n        self.root.mkdir(parents=True, exist_ok=True)\n        # Jobs\n        self.jobs: Dict[int, Job] = {}\n        self.lastJid = 0\n        self.running = False\n        self.lock = threading.RLock()\n        # PID file\n        self.pidFile = self.root / \"farm.pid\"\n        self.pidFile.write_text(str(os.getpid()))\n        # Socket path\n        self.tcpPortFile = self.root / \"backend.port\"\n        logger.info(f\"Backend initialized at {self.root}\")\n        self.maxParallel: int = maxParallel\n\n    def start(self):\n        \"\"\" Start the server. \"\"\"\n        logger.info(f\"Starting the server...\")\n        # Start the server to listen to queries\n        self.running = True\n        handler = lambda *args: LocalFarmRequestHandler(self, *args)\n        self.server = ThreadingTCPServer(('localhost', 0), handler)\n        port = self.server.server_address[1]\n        self.tcpPortFile.write_text(str(port))\n        logger.info(f\"Server listening on TCP port: {port}\")\n        # Start server in separate thread\n        serverThread = threading.Thread(target=self.server.serve_forever, daemon=True)\n        serverThread.start()\n        # Start task processor\n        processThread = threading.Thread(target=self.taskRunner, daemon=True)\n        processThread.start()\n        # Wait for shutdown signal\n        signal.signal(signal.SIGTERM, self.signalHandler)\n        signal.signal(signal.SIGINT, self.signalHandler)\n        try:\n            while self.running:\n                time.sleep(1)\n        finally:\n            self.cleanup()\n\n    def signalHandler(self, signum, frame):\n        logger.info(f\"Received signal {signum}, shutting down...\")\n        self.running = False\n\n    def taskRunner(self):\n        \"\"\"Background thread that processes tasks\"\"\"\n        while self.running:\n            try:\n                with self.lock:\n                    self.processJobs()\n                time.sleep(0.5)\n            except Exception as e:\n                logger.error(f\"Error in task processor: {e}\", exc_info=True)\n\n    def processJobs(self):\n        \"\"\" Process all active jobs. \"\"\"\n        runningTasks = defaultdict(list)\n        tasksToStart = defaultdict(list)\n        for job in self.jobs.values():\n            job.updateStatusFromTasks()\n            if not job.submitted or job.status in [Status.PAUSED, Status.SUCCESS, Status.STOPPED]:\n                continue\n            elif job.status == Status.SUBMITTED:\n                job.start()\n            # Update running tasks\n            runningTasks[job.jid] = [t for t in job.tasks if t.status == Status.RUNNING]\n            # Update tasks to start\n            for task in job.tasks:\n                if task.status == Status.SUBMITTED:\n                    if job.canStartTask(task):\n                        tasksToStart[job].append(task)\n                elif task.status == Status.RUNNING and task.process:\n                    # Check if process finished\n                    returncode = task.process.poll()\n                    if returncode is not None:\n                        self.finishTask(task, returncode)\n\n            # Check if job is complete\n            if any(t.status in [Status.ERROR, Status.STOPPED, Status.KILLED] for t in job.tasks):\n                job.status = Status.ERROR\n                logger.error(f\"Job {job.jid} failed !\")\n            elif all(t.status in [Status.SUCCESS, Status.NONE] for t in job.tasks):\n                job.status = Status.SUCCESS\n                logger.info(f\"Job {job.jid} finished !\")\n            # else : keep running or paused\n\n        # Launch tasks\n        nbRunningTasks = sum(len(tasks) for tasks in runningTasks.values())\n        tasks = []\n        for job, jobTasks in tasksToStart.items():\n            # while True:\n            #     nextTask = job.getNextTaskToProcess()\n            #     if not nextTask:\n            #         break\n            for task in jobTasks:\n                tasks.append((job, task))\n        random.shuffle(tasks)  # Randomize task order to be fair between jobs\n        for job, task in tasks:\n            nbJobRunningTasks = len(runningTasks[job.jid])\n            if job.maxParallel > nbJobRunningTasks and self.maxParallel > nbRunningTasks:\n                nbRunningTasks += 1\n                nbJobRunningTasks += 1\n                self.startTask(task)\n\n    def startTask(self, task: Task):\n        \"\"\" Start a task process. \"\"\"\n        logger.info(f\"Starting task {task.tid}: {task.command}\")\n        task.status = Status.RUNNING\n        task.started_at = datetime.now()\n        # Create log file\n        additional_env = {\n            \"LOCALFARM_CURRENT_JID\": str(task.jid),\n            \"LOCALFARM_CURRENT_TID\": str(task.tid),\n            \"MR_LOCAL_FARM_PATH\": str(self.root)\n        }\n        additional_env.update(task.env)\n        process_env = os.environ.copy()\n        process_env.update(additional_env)\n        try:\n\n            with open(task.logFile, \"w\") as log:\n                log.write(f\"# ========== Starting task {task.tid} at {task.started_at.isoformat()}\"\n                          f\" (command=\\\"{task.command}\\\") ==========\\n\")\n                log.write(f\"# process_env:\\n\")\n                log.write(f\"# Additional env variables:\\n\")\n                for _k, _v in additional_env.items():\n                    log.write(f\"# - {str(_k)}={str(_v)}\\n\")\n                log.write(f\"\\n\")\n                task.process = subprocess.Popen(\n                    task.command,\n                    # shlex.split(task.command),\n                    stdout=log,\n                    stderr=log,\n                    cwd=task.taskDir,\n                    env=process_env,\n                    shell=True\n                )\n        except Exception as e:\n            logger.error(f\"Failed to start task {task.tid}: {e}\")\n            task.status = \"error\"\n            task.finished_at = datetime.now()\n\n    def finishTask(self, task: Task, returncode: int):\n        task.finished_at = datetime.now()\n        task.return_code = returncode\n        if returncode == 0:\n            task.status = Status.SUCCESS\n            logger.info(f\"Task {task.tid} completed\")\n        else:\n            task.status = Status.ERROR\n            logger.error(f\"Task {task.tid} failed with code {returncode}\")\n        with open(task.logFile, \"a\") as log:\n            log.write(f\"\\n# ========== Task {task.tid} finished at {task.finished_at.isoformat()} with status {task.status} ==========\\n\")\n\n    def cleanup(self):\n        logger.info(\"Cleaning up...\")\n        with self.lock:\n            for job in self.jobs.values():\n                for task in job.tasks:\n                    if task.process and task.process.poll() is None:\n                        logger.info(f\"Terminating task {task.tid}\")\n                        task.process.terminate()\n                        try:\n                            task.process.wait(timeout=5)\n                        except subprocess.TimeoutExpired:\n                            task.process.kill()\n        self.server.shutdown()\n        self.pidFile.unlink(missing_ok=True)\n        logger.info(\"Cleanup complete\")\n\n    # ======================\n    # API Calls\n    # ======================\n\n    # Author\n\n    def create_job(self, name):\n        \"\"\" Create a new job. \"\"\"\n        with self.lock:\n            # Generate new jid\n            self.lastJid += 1\n            jid = self.lastJid\n            try:\n                job = Job(jid, label=name, farmRoot=self.root)\n            except Exception as err:\n                return {\"success\": False, \"error\": str(err)}\n            self.jobs[jid] = job\n            logger.info(f\"Created job {jid}\")\n            return {\"success\": True, \"jid\": jid}\n\n    def create_task(self, jid, name, command, metadata, dependencies, env=None):\n        \"\"\" Add a task to a job. \"\"\"\n        with self.lock:\n            if jid not in self.jobs:\n                return {\"success\": False, \"error\": \"Job not found\"}\n            job = self.jobs[jid]\n            job.lastJid += 1\n            tid = job.lastJid\n            task = Task(jid, tid, name, command, metadata, job.jobDir, env=env)\n            job.tasks.append(task)\n            for parentTid in dependencies:\n                parentTask = next((t for t in job.tasks if t.tid == parentTid), None)\n                if parentTask:\n                    job.addTaskDependency(parentTask, task)\n                else:\n                    logger.warning(f\"Task {tid} : Cannot add dependency to {parentTid}, task not found in job {jid}\")\n            logger.info(f\"Added task {tid} to job {jid}\")\n            return {\"success\": True, \"tid\": tid}\n\n    def expand_task(self, jid, name, command, metadata, parentTid, env=None):\n        with self.lock:\n            if jid not in self.jobs:\n                logger.info(f\"Available jobs: {list(self.jobs.keys())}\")\n                return {\"success\": False, \"error\": \"Job not found\"}\n            job = self.jobs[jid]\n            job.lastJid += 1\n            tid = job.lastJid\n            task = Task(jid, tid, name, command, metadata, job.jobDir, env=env)\n            task.status = Status.SUBMITTED\n            job.tasks.append(task)\n            parentTask = next((t for t in job.tasks if t.tid == parentTid), None)\n            if not parentTask:\n                logger.error(f\"Could not expand task {parentTid} : cannot find it in the job {job} ({jid})\")\n                return {\"success\": False, \"error\": f\"Parent task {parentTid} not found in job {jid}\"}\n            for childTid in parentTask.childTids:\n                childTask = next((t for t in job.tasks if t.tid == childTid), None)\n                if not childTask:\n                    logger.error(f\"Could not find expanded task child {childTid}\")\n                job.addTaskDependency(task, childTask)\n            logger.info(f\"Added expanded task {tid} to job {jid}\")\n            return {\"success\": True, \"tid\": tid}\n\n    def submit_job(self, jid):\n        \"\"\" Create a new job. \"\"\"\n        with self.lock:\n            if jid not in self.jobs:\n                return {'success': False, \"error\": \"Job not found\"}\n            try:\n                job = self.jobs[jid]\n                job.submitted = True\n                job.status = Status.SUBMITTED\n            except Exception as err:\n                return {\"success\": False, \"error\": str(err)}\n            logger.info(f\"Submitted job {jid}\")\n            return {\"success\": True, \"jid\": jid}\n\n    # Query\n\n    def get_job_info(self, jid):\n        \"\"\" Get job status. \"\"\"\n        with self.lock:\n            if jid not in self.jobs:\n                return {'success': False, \"error\": \"Job not found\"}\n            job = self.jobs[jid]\n            return {\"success\": True, \"result\": job.to_dict()}\n\n    def get_job_errors(self, jid):\n        \"\"\" Get job error logs. \"\"\"\n        with self.lock:\n            if jid not in self.jobs:\n                return {'success': False, \"error\": \"Job not found\"}\n            job = self.jobs[jid]\n            return {\"success\": True, \"result\": job.errorLogs}\n\n    def pause_job(self, jid):\n        \"\"\" Pause a job. \"\"\"\n        with self.lock:\n            if jid not in self.jobs:\n                return {\"success\": False, \"error\": \"Job not found\"}\n            self.jobs[jid].status = Status.PAUSED\n            logger.info(f\"Job {jid} paused\")\n            return {\"success\": True}\n\n    def unpause_job(self, jid):\n        \"\"\" Resume a job. \"\"\"\n        with self.lock:\n            if jid not in self.jobs:\n                return {\"success\": False, \"error\": \"Job not found\"}\n            self.jobs[jid].resume()\n            return {\"success\": True}\n\n    def interrupt_job(self, jid):\n        \"\"\" Interrupt a job and kill running tasks. \"\"\"\n        with self.lock:\n            if jid not in self.jobs:\n                return {\"success\": False, \"error\": \"Job not found\"}\n            self.jobs[jid].interrupt()\n            return {\"success\": True}\n\n    def restart_job(self, jid):\n        \"\"\" Restarts a job and kill running tasks. \"\"\"\n        with self.lock:\n            if jid not in self.jobs:\n                return {\"success\": False, \"error\": \"Job not found\"}\n            self.jobs[jid].restart()\n            return {\"success\": True}\n\n    def restart_error_tasks(self, jid):\n        \"\"\" Restarts all error tasks in the job. \"\"\"\n        with self.lock:\n            if jid not in self.jobs:\n                return {\"success\": False, \"error\": \"Job not found\"}\n            self.jobs[jid].restartErrorTasks()\n            return {\"success\": True}\n\n    def stop_task(self, jid, tid):\n        \"\"\" Stop a specific task. \"\"\"\n        with self.lock:\n            if jid not in self.jobs:\n                return {\"success\": False, \"error\": \"Job not found\"}\n            res = self.jobs[jid].stopTask(tid)\n            if res:\n                return {\"success\": True}\n            else:\n                return {\"success\": False, \"error\": \"Task not found\"}\n\n    def skip_task(self, jid, tid):\n        \"\"\" Stop a specific task. \"\"\"\n        with self.lock:\n            if jid not in self.jobs:\n                return {\"success\": False, \"error\": \"Job not found\"}\n            res = self.jobs[jid].skipTask(tid)\n            if res:\n                return {\"success\": True}\n            else:\n                return {\"success\": False, \"error\": \"Task not found\"}\n\n    def restart_task(self, jid, tid):\n        \"\"\" Restart a task. \"\"\"\n        with self.lock:\n            if jid not in self.jobs:\n                return {\"success\": False, \"error\": \"Job not found\"}\n            res = self.jobs[jid].restartTask(tid)\n            if res:\n                return {\"success\": True}\n            else:\n                return {\"success\": False, \"error\": \"Task not found\"}\n\n    def list_jobs(self):\n        \"\"\" List all jobs. \"\"\"\n        with self.lock:\n            return {\n                \"success\": True,\n                \"jobs\": [job.to_dict() for job in self.jobs.values()]\n            }\n\n\nclass LocalFarmRequestHandler(BaseRequestHandler):\n    \"\"\" Handle requests. \"\"\"\n\n    def __init__(self, backend, *args, **kwargs):\n        self.backend = backend\n        super().__init__(*args, **kwargs)\n\n    @property\n    def pid(self):\n        return self.server.server_address[1]\n\n    def handle(self):\n        \"\"\" Handle incoming request. \"\"\"\n        try:\n            # Read request\n            data = b\"\"\n            while True:\n                token = self.request.recv(MAX_BYTES_REQUEST)\n                if not token:\n                    break\n                data += token\n                if b\"\\n\" in token:\n                    break\n            if not data:\n                return\n            request = json.loads(data.decode(\"utf-8\"))\n            logger.debug(f\"Received request: {request}\")\n            # Dispatch method\n            method = request.get(\"method\")\n            params = request.get(\"params\", {})\n            if not hasattr(self.backend, method):\n                response = {\"success\": False, \"error\": f\"Unknown request: {method}\"}\n            else:\n                try:\n                    result = getattr(self.backend, method)(**params)\n                    response = result\n                except Exception as e:\n                    logger.error(f\"Error executing {method}: {e}\", exc_info=True)\n                    response = {'success': False, 'error': str(e)}\n            # Send response\n            response_data = json.dumps(response) + '\\n'\n            self.request.sendall(response_data.encode('utf-8'))\n\n        except Exception as e:\n            logger.error(f\"Error handling request: {e}\", exc_info=True)\n\n\ndef main(root):\n    # Daemonize\n    if os.fork() > 0:\n        sys.exit(0)\n    os.setsid()\n    if os.fork() > 0:\n        sys.exit(0)\n\n    # Redirect standard file descriptors\n    sys.stdout.flush()\n    sys.stderr.flush()\n    with open(os.devnull, 'r') as devnull:\n        os.dup2(devnull.fileno(), sys.stdin.fileno())\n\n    backend = LocalFarmEngine(root=root)\n    backend.start()\n\n\nif __name__ == \"__main__\":\n    parser = argparse.ArgumentParser(description='Execute a Graph of processes.')\n    parser.add_argument('--root', type=str, required=False, help='Root path for the farm.')\n    args = parser.parse_args()\n    root = args.root\n    if not root:\n        root = os.getenv(\"MR_LOCAL_FARM_PATH\", os.path.join(os.path.expanduser(\"~\"), \".local_farm\"))\n    main(root)\n"
  },
  {
    "path": "localfarm/localFarmLauncher.py",
    "content": "#!/usr/bin/env python\n\nimport os\nimport shutil\nimport sys\nimport time\nimport signal\nimport argparse\nfrom pathlib import Path\nimport subprocess\nfrom collections import defaultdict\n\nfrom localfarm.localFarm import LocalFarmEngine\n\n\nclass FarmLauncher:\n    def __init__(self, root=None):\n        self.root = Path(root or Path.home() / \".local_farm\")\n        self.root.mkdir(parents=True, exist_ok=True)\n        self.pidFile = self.root / \"farm.pid\"\n        self.logFile = self.root / \"backend.log\"\n\n    def clean(self):\n        \"\"\" Clean farm backend files. \"\"\"\n        print(\"Clean farm files...\")\n        if self.logFile.exists():\n            self.logFile.unlink()\n        if (self.root / \"jobs\").exists():\n            shutil.rmtree(str((self.root / \"jobs\")))\n        if not self.is_running():\n            self.pidFile.unlink(missing_ok=True)\n            (self.root / \"backend.port\").unlink(missing_ok=True)\n        print(\"Done.\")\n\n    def start(self):\n        \"\"\" Start the farm backend. \"\"\"\n        if self.is_running():\n            print(\"Farm backend is already running\")\n            return\n        self.clean()\n\n        print(\"Starting farm backend...\")\n        print(f\"Farm root is: {self.root}\")\n        # Get path to backend script\n        backendScript = Path(__file__).parent / \"localFarmBackend.py\"\n        # Start backend as daemon\n        with open(self.logFile, 'a') as log:\n            subprocess.Popen(\n                [sys.executable, str(backendScript), \"--root\", str(self.root)],\n                stdout=log,\n                stderr=log,\n                # stderr=subprocess.PIPE,\n                start_new_session=True\n            )\n\n        # Wait for it to start\n        for _ in range(10):\n            time.sleep(0.5)\n            if self.is_running():\n                print(f\"Farm backend started (PID: {self.getFarmPid()})\")\n                print(f\"Logs: {self.logFile}\")\n                return\n\n        print(\"Failed to start farm backend\")\n        sys.exit(1)\n\n    def stop(self):\n        \"\"\" Stop the farm backend. \"\"\"\n        if not self.is_running():\n            print(\"Farm backend is not running\")\n            return\n\n        pid = self.getFarmPid()\n        print(f\"Stopping farm backend (PID: {pid})...\")\n\n        try:\n            os.kill(pid, signal.SIGTERM)\n\n            # Wait for it to stop\n            for _ in range(10):\n                time.sleep(0.5)\n                if not self.is_running():\n                    print(\"Farm backend stopped\")\n                    return\n\n            # Force kill if still running\n            print(\"Force killing farm backend...\")\n            os.kill(pid, signal.SIGKILL)\n\n        except ProcessLookupError:\n            print(\"Backend process not found\")\n            self.pidFile.unlink(missing_ok=True)\n\n    def restart(self):\n        \"\"\"Restart the farm backend\"\"\"\n        self.stop()\n        time.sleep(1)\n        self.start()\n\n    def getJobsInfo(self):\n        if self.is_running():\n            # Try to get job list\n            try:\n                engine = LocalFarmEngine(root=self.root)\n                jobs = engine.list_jobs()\n                return jobs\n            except Exception as e:\n                raise ValueError(f\"Could not fetch jobs: {e}\")\n        else:\n            print(\"Farm backend is not running\")\n            return []\n\n    def status(self, allInfo=False):\n        \"\"\" Show status of the farm backend. \"\"\"\n        if self.is_running():\n            pid = self.getFarmPid()\n            print(f\"Farm backend is running (PID: {pid})\")\n\n            # Try to get job list\n            try:\n                engine = LocalFarmEngine(root=self.root)\n                jobs = engine.list_jobs()\n                print(f\"Active jobs: {len(jobs)}\")\n                for job in jobs:\n                    jid = job.get(\"jid\")\n                    taskByStatus = defaultdict(set)\n                    for task in job['tasks']:\n                        status = task.get(\"status\", \"UNKNOWN\")\n                        taskByStatus[status].add(task.get(\"tid\"))\n                    print(f\"  - {jid}: {job['status']} ({len(job['tasks'])} tasks) -> {dict(taskByStatus)}\")\n                    if allInfo:\n                        for task in job['tasks']:\n                            print(f\"      * Task {task['tid']}: {task}\")\n                    print(\"\")\n            except Exception as e:\n                print(f\"Could not get job list: {e}\")\n        else:\n            print(\"Farm backend is not running\")\n\n    def is_running(self):\n        \"\"\" Check if backend is running. \"\"\"\n        pid = self.getFarmPid()\n        if pid is None:\n            return False\n        try:\n            os.kill(pid, 0)\n            return True\n        except ProcessLookupError:\n            return False\n\n    def getFarmPid(self):\n        \"\"\" Get PID of running backend. \"\"\"\n        if not self.pidFile.exists():\n            return None\n        try:\n            return int(self.pidFile.read_text())\n        except Exception:\n            return None\n\n\ndef main(root, command):\n    launcher = FarmLauncher(root=root)\n    if command == 'clean':\n        return launcher.clean()\n    if command == 'start':\n        return launcher.start()\n    elif command == 'stop':\n        return launcher.stop()\n    elif command == 'restart':\n        return launcher.restart()\n    elif command == 'status':\n        return launcher.status()\n    elif command == 'fullInfo':\n        return launcher.status(allInfo=True)\n\n\nif __name__ == \"__main__\":\n    parser = argparse.ArgumentParser(description='Local Farm Launcher')\n    parser.add_argument('command',\n                        choices=['clean', 'start', 'stop', 'restart', 'status', 'fullInfo'],\n                        help='Command to execute')\n    parser.add_argument('--root', required=False, help='Farm directory path')\n    args = parser.parse_args()\n\n    root = args.root\n    if not root:\n        root = os.getenv(\"MR_LOCAL_FARM_PATH\", os.path.join(os.path.expanduser(\"~\"), \".local_farm\"))\n\n    main(root, args.command)\n"
  },
  {
    "path": "localfarm/test.py",
    "content": "#!/usr/bin/env python\n\nimport os\nfrom time import sleep\nfrom localfarm.localFarm import Task, Job, LocalFarmEngine\nfrom localfarm.localFarmLauncher import FarmLauncher\nfrom collections import defaultdict\nfrom typing import List\n\n\nclass TestLocalFarm:\n    def __init__(self, farmPath):\n        self.launcher = FarmLauncher(root=farmPath)\n        self.engine = LocalFarmEngine(farmPath)\n\n    def prepare(self):\n        self.launcher.clean()\n        self.launcher.start()\n\n    def createTask(self, job: Job, i: int, sleepTime=2, dependencies: List[Task] = None):\n        dependencies = dependencies or []\n        task = Task(f\"Task {i}\", f\"echo 'Hello from Task {i}' && sleep {sleepTime}\")\n        job.addTask(task)\n        for parentTask in dependencies:\n            job.addTaskDependency(task, parentTask)\n        return task\n\n    def expandTask(self, jid, tid, n=2):\n        for i in range(n):\n            task = Task(f\"Expanded Task {i}\", f\"echo 'Hello from Expanded Task {i}' && sleep 5\")\n            self.engine.create_additional_task(jid, tid, task)\n\n    def getTasksByStatus(self, jid: int):\n        jobInfo = self.engine.get_job_status(jid)\n        if not jobInfo:\n            return {}\n        taskByStatus = defaultdict(set)\n        for task in jobInfo.get(\"tasks\", []):\n            status = task.get(\"status\", \"UNKNOWN\")\n            taskByStatus[status].add(task.get(\"tid\"))\n        return dict(taskByStatus)\n\n    def run(self):\n        # Create job\n        job = Job(\"Example Job\")\n        job.setEngine(self.engine)\n        # Add tasks\n        task1 = self.createTask(job, 1, sleepTime=2, dependencies=[])\n        task2 = self.createTask(job, 2, sleepTime=2, dependencies=[task1])\n        task3 = self.createTask(job, 3, sleepTime=2, dependencies=[task1])\n        task4 = self.createTask(job, 4, sleepTime=2, dependencies=[task2, task3])\n        task5 = self.createTask(job, 5, sleepTime=2, dependencies=[task4])\n        # Submit job\n        res = job.submit()\n        jid = res['jid']\n        # Monitor job\n        currentRunningTids = set()\n        hasExpanded = False\n        while True:\n            sleep(1)\n            tasks = self.getTasksByStatus(jid)\n            if not tasks:\n                print(\"No tasks found for job\")\n                break\n            runningTids = tasks.get(\"RUNNING\")\n            activeTasks = tasks.get(\"SUBMITTED\", set()).union(tasks.get(\"RUNNING\", set()))\n            if not activeTasks:\n                print(\"All tasks completed\")\n                break\n            if runningTids:\n                runningTids = [int(t) for t in runningTids]\n                newRunningTasks = set(runningTids)\n                if currentRunningTids != newRunningTasks:\n                    print(f\"Now running tasks: {runningTids} (active tasks: {activeTasks})\")\n                    currentRunningTids = newRunningTasks\n                expandingTid = 5\n                if not hasExpanded and expandingTid in runningTids:\n                    hasExpanded = True\n                    print(f\"Expanding task {expandingTid}\")\n                    self.expandTask(jid, expandingTid, n=2)\n\n    def finish(self):\n        self.launcher.stop()\n        # self.launcher.clean()\n\n\ndef test():\n    farm_path = os.getenv(\"MR_LOCAL_FARM_PATH\", os.path.join(os.path.expanduser(\"~\"), \".local_farm\"))\n    # farm_path = \"/s/prods/mvg/_source_global/users/sonoleta/tmp/local_farm\"\n    _test = TestLocalFarm(farm_path)\n    try:\n        _test.prepare()\n        _test.run()\n    except Exception as e:\n        print(f\"Test failed: {e}\")\n        _test.finish()\n        raise e\n    finally:\n        _test.finish()\n\n\nif __name__ == \"__main__\":\n    test()\n"
  },
  {
    "path": "meshroom/__init__.py",
    "content": "from enum import Enum, IntEnum\nimport logging\nimport os\nimport sys\n\n\nclass VersionStatus(Enum):\n    release = 1\n    develop = 2\n\n\n__version__ = \"2026.1.0\"\n# Always increase the minor version when switching from release to develop.\n__version_status__ = VersionStatus.develop\n\nif __version_status__ is VersionStatus.develop:\n    __version__ += \"+\" + __version_status__.name\n\n__version_label__ = __version__\n# Modify version label if we are in a development phase.\nif __version_status__ is VersionStatus.develop:\n\n    scriptPath = os.path.dirname(os.path.abspath(__file__))\n    headFilepath = os.path.join(scriptPath, \"../.git/HEAD\")\n    if os.path.exists(headFilepath):\n        # Add git branch name, if it is a git repository\n        with open(headFilepath, \"r\") as headFile:\n            data = headFile.readlines()\n            branchName = data[0].split('/')[-1].strip()\n            __version_label__ += \" branch=\" + branchName\n\n    # Allow override from env variable\n    if \"REZ_MESHROOM_VERSION\" in os.environ:\n        __version_label__ += \" package=\" + os.environ.get(\"REZ_MESHROOM_VERSION\")\n\n\n# Internal imports after the definition of the version\nfrom .common import init, Backend, strtobool\n\n# sys.frozen is initialized by cx_Freeze and identifies a release package\nisFrozen = getattr(sys, \"frozen\", False)\n\nuseMultiChunks = bool(strtobool(os.environ.get(\"MESHROOM_USE_MULTI_CHUNKS\", \"True\")))\n\n\n# Logging\ndef addTraceLevel():\n    \"\"\" From https://stackoverflow.com/a/35804945 \"\"\"\n    levelName, methodName, levelNum = 'TRACE', 'trace', logging.DEBUG - 5\n    if hasattr(logging, levelName) or hasattr(logging, methodName)or hasattr(logging.getLoggerClass(), methodName):\n        return\n\n    def logForLevel(self, message, *args, **kwargs):\n        if self.isEnabledFor(levelNum):\n            self._log(levelNum, message, args, **kwargs)\n\n    def logToRoot(message, *args, **kwargs):\n        logging.log(levelNum, message, *args, **kwargs)\n\n    logging.addLevelName(levelNum, levelName)\n    setattr(logging, levelName, levelNum)\n    setattr(logging.getLoggerClass(), methodName, logForLevel)\n    setattr(logging, methodName, logToRoot)\n\n\naddTraceLevel()\nlogStringToPython = {\n    'fatal': logging.CRITICAL,\n    'error': logging.ERROR,\n    'warning': logging.WARNING,\n    'info': logging.INFO,\n    'debug': logging.DEBUG,\n    'trace': logging.TRACE,\n}\nlogging.getLogger().setLevel(logStringToPython[os.environ.get('MESHROOM_VERBOSE', 'warning')])\n\n\nclass MeshroomExitStatus(IntEnum):\n    \"\"\" In case we want to catch some special case from the parent process\n    We could use 3-125 for custom exist codes :\n    https://tldp.org/LDP/abs/html/exitcodes.html\n    \"\"\"\n    SUCCESS = 0\n    ERROR = 1\n    # In some farm tools jobs are automatically re-tried, using ERROR_NO_RETRY will try to prevent that\n    ERROR_NO_RETRY = -999  # It's actually -999 % 256 => 25\n\n\ndef setupEnvironment(backend=Backend.STANDALONE):\n    \"\"\"\n    Setup environment for Meshroom to work in a prebuilt, standalone configuration.\n\n    Use 'MESHROOM_INSTALL_DIR' to simulate standalone configuration with a path to a Meshroom installation folder.\n\n    # Meshroom standalone structure\n\n    - Meshroom/\n       - aliceVision/\n           - bin/    # runtime bundled binaries (windows: exe + libs, unix: executables)\n           - lib/    # runtime bundled libraries (unix: libs)\n           - share/  # resource files\n               - aliceVision/\n                   - COPYING.md         # AliceVision COPYING file\n                   - cameraSensors.db   # sensor database\n                   - vlfeat_K80L3.tree  # voctree file\n       - lib/      # Python lib folder\n       - qtPlugins/\n       - plugins/\n       Meshroom    # main executable\n       COPYING.md  # Meshroom COPYING file\n    \"\"\"\n\n    init(backend)\n\n    def addToEnvPath(var, val, index=-1):\n        \"\"\"\n        Add paths to the given environment variable.\n\n        Args:\n            var (str): the name of the variable to add paths to\n            val (str or list of str): the path(s) to add\n            index (int): insertion index\n        \"\"\"\n        if not val:\n            return\n\n        paths = os.environ.get(var, \"\").split(os.pathsep)\n\n        if not isinstance(val, (list, tuple)):\n            val = [val]\n\n        if index == -1:\n            paths.extend(val)\n        elif index == 0:\n            paths = val + paths\n        else:\n            raise ValueError(\"addToEnvPath: index must be -1 or 0.\")\n        os.environ[var] = os.pathsep.join(paths)\n\n    # setup root directory (override possible by setting \"MESHROOM_INSTALL_DIR\" environment variable)\n    rootDir = os.path.dirname(sys.executable) if isFrozen else os.environ.get(\"MESHROOM_INSTALL_DIR\", None)\n    logging.debug(f\"isFrozen={isFrozen}\")\n    logging.debug(f\"sys.executable={sys.executable}\")\n    logging.debug(f\"rootDir={rootDir}\")\n\n    if rootDir:\n        os.environ[\"MESHROOM_INSTALL_DIR\"] = rootDir\n\n        aliceVisionDir = os.path.join(rootDir, \"aliceVision\")\n        # default directories\n        aliceVisionBinDir = os.path.join(aliceVisionDir, \"bin\")\n        aliceVisionShareDir = os.path.join(aliceVisionDir, \"share\", \"aliceVision\")\n        qtPluginsDir = os.path.join(rootDir, \"qtPlugins\")\n        pluginsDir = os.path.join(rootDir, \"plugins\")\n        sensorDBPath = os.path.join(aliceVisionShareDir, \"cameraSensors.db\")\n        voctreePath = os.path.join(aliceVisionShareDir, \"vlfeat_K80L3.SIFT.tree\")\n        sphereDetectionModel = os.path.join(aliceVisionShareDir, \"sphereDetection_Mask-RCNN.onnx\")\n        semanticSegmentationModel = os.path.join(aliceVisionShareDir, \"fcn_resnet50.onnx\")\n        colorChartDetectionModelFolder = os.path.join(aliceVisionShareDir, \"ColorChartDetectionModel\")\n\n        env = {\n            \"PATH\": aliceVisionBinDir,\n            \"QT_PLUGIN_PATH\": [qtPluginsDir],\n            \"QML2_IMPORT_PATH\": [os.path.join(qtPluginsDir, \"qml\")]\n        }\n\n        for key, value in env.items():\n            logging.debug(f\"Add to {key}: {value}\")\n            addToEnvPath(key, value, 0)\n\n        # Add all available plugins\n        if os.path.exists(pluginsDir):\n            subfolders = [f.path for f in os.scandir(pluginsDir) if f.is_dir()]\n            for plugin in subfolders:\n                addToEnvPath(\"MESHROOM_PLUGINS_PATH\", plugin, 0)\n\n        variables = {\n            \"ALICEVISION_ROOT\": aliceVisionDir,\n            \"ALICEVISION_SENSOR_DB\": sensorDBPath,\n            \"ALICEVISION_VOCTREE\": voctreePath,\n            \"ALICEVISION_SPHERE_DETECTION_MODEL\": sphereDetectionModel,\n            \"ALICEVISION_SEMANTIC_SEGMENTATION_MODEL\": semanticSegmentationModel,\n            \"ALICEVISION_COLORCHARTDETECTION_MODEL_FOLDER\": colorChartDetectionModelFolder\n        }\n\n        for key, value in variables.items():\n            if key not in os.environ and os.path.exists(value):\n                logging.debug(f\"Set {key}: {value}\")\n                os.environ[key] = value\n\n        # Add nodes and templates from AliceVision\n        aliceVisionPluginDir = os.path.join(aliceVisionDir, \"share\", \"meshroom\")\n        addToEnvPath(\"MESHROOM_NODES_PATH\", aliceVisionPluginDir)\n        addToEnvPath(\"MESHROOM_PIPELINE_TEMPLATES_PATH\", aliceVisionPluginDir)\n\n    addToEnvPath(\"PATH\", os.environ.get(\"ALICEVISION_BIN_PATH\", \"\"))\n    if sys.platform == \"win32\":\n        addToEnvPath(\"PATH\", os.environ.get(\"ALICEVISION_LIBPATH\", \"\"))\n    else:\n        addToEnvPath(\"LD_LIBRARY_PATH\", os.environ.get(\"ALICEVISION_LIBPATH\", \"\"))\n\n\nos.environ[\"QML_XHR_ALLOW_FILE_READ\"] = '1'\nos.environ[\"QML_XHR_ALLOW_FILE_WRITE\"] = '1'\nos.environ[\"PYSEQ_STRICT_PAD\"] = '1'\nos.environ[\"QSG_RHI_BACKEND\"] = \"opengl\"\n"
  },
  {
    "path": "meshroom/common/PySignal.py",
    "content": "# https://github.com/dgovil/PySignal\n\n__author__ = \"Dhruv Govil\"\n__copyright__ = \"Copyright 2016, Dhruv Govil\"\n__credits__ = [\"Dhruv Govil\", \"John Hood\", \"Jason Viloria\", \"Adric Worley\", \"Alex Widener\"]\n__license__ = \"MIT\"\n__version__ = \"1.1.3\"\n__maintainer__ = \"Dhruv Govil\"\n__email__ = \"dhruvagovil@gmail.com\"\n__status__ = \"Beta\"\n\nimport inspect\nimport sys\nimport weakref\nfrom functools import partial\nfrom weakref import WeakMethod\n\n\nclass Signal:\n    \"\"\"\n    The Signal is the core object that handles connection and emission .\n    \"\"\"\n\n    def __init__(self):\n        super().__init__()\n        self._block = False\n        self._sender = None\n        self._slots = []\n\n    def __call__(self, *args, **kwargs):\n        self.emit(*args, **kwargs)\n\n    def emit(self, *args, **kwargs):\n        \"\"\"\n        Calls all the connected slots with the provided args and kwargs unless block is activated\n        \"\"\"\n        if self._block:\n            return\n\n        def _get_sender():\n            \"\"\"Try to get the bound, class or module method calling the emit.\"\"\"\n            prev_frame = sys._getframe(2)\n            func_name = prev_frame.f_code.co_name\n\n            # Faster to try/catch than checking for 'self'\n            try:\n                return getattr(prev_frame.f_locals['self'], func_name)\n\n            except KeyError:\n                return getattr(inspect.getmodule(prev_frame), func_name)\n\n        # Get the sender\n        try:\n            self._sender = WeakMethod(_get_sender())\n\n        # Account for when func_name is at '<module>'\n        except AttributeError:\n            self._sender = None\n\n        # Handle unsupported module level methods for WeakMethod.\n        # TODO: Support module level methods.\n        except TypeError:\n            self._sender = None\n\n        for slot in self._slots:\n            if not slot:\n                continue\n            elif isinstance(slot, partial):\n                slot()\n            elif isinstance(slot, weakref.WeakKeyDictionary):\n                # For class methods, get the class object and call the method accordingly.\n                for obj, method in slot.items():\n                    method(obj, *args, **kwargs)\n            elif isinstance(slot, weakref.ref):\n                # If it is a weakref, call the ref to get the instance and then call the func\n                # Do not wrap in try/except so we do not risk masking exceptions from the actual func call\n                tested_slot = slot()\n                if tested_slot is not None:\n                    tested_slot(*args, **kwargs)\n            else:\n                # Else call it in a standard way. Should be just lambdas at this point\n                slot(*args, **kwargs)\n\n    def connect(self, slot):\n        \"\"\"\n        Connects the signal to any callable object\n        \"\"\"\n        if not callable(slot):\n            raise ValueError(f\"Connection to non-callable '{slot.__class__.__name__}' object failed\")\n\n        if isinstance(slot, (partial, Signal)) or '<' in slot.__name__:\n            # If it is a partial, a Signal or a lambda. The '<' check is the only py2 and py3 compatible way I could find\n            if slot not in self._slots:\n                self._slots.append(slot)\n        elif inspect.ismethod(slot):\n            # Check if it is an instance method and store it with the instance as the key\n            slotSelf = slot.__self__\n            slotDict = weakref.WeakKeyDictionary()\n            slotDict[slotSelf] = slot.__func__\n            if slotDict not in self._slots:\n                self._slots.append(slotDict)\n        else:\n            # If it is just a function then just store it as a weakref.\n            newSlotRef = weakref.ref(slot)\n            if newSlotRef not in self._slots:\n                self._slots.append(newSlotRef)\n\n    def disconnect(self, slot):\n        \"\"\"\n        Disconnects the slot from the signal\n        \"\"\"\n        if not callable(slot):\n            return\n\n        if inspect.ismethod(slot):\n            # If it is a method, then find it by its instance\n            slotSelf = slot.__self__\n            for s in self._slots:\n                if (isinstance(s, weakref.WeakKeyDictionary) and\n                        (slotSelf in s) and\n                        (s[slotSelf] is slot.__func__)):\n                    self._slots.remove(s)\n                    break\n        elif isinstance(slot, (partial, Signal)) or '<' in slot.__name__:\n            # If it is a partial, a Signal or lambda, try to remove directly\n            try:\n                self._slots.remove(slot)\n            except ValueError:\n                pass\n        else:\n            # It's probably a function, so try to remove by weakref\n            try:\n                self._slots.remove(weakref.ref(slot))\n            except ValueError:\n                pass\n\n    def clear(self):\n        \"\"\"Clears the signal of all connected slots\"\"\"\n        self._slots = []\n\n    def block(self, isBlocked):\n        \"\"\"Sets blocking of the signal\"\"\"\n        self._block = bool(isBlocked)\n\n    def sender(self):\n        \"\"\"Return the callable responsible for emitting the signal, if found.\"\"\"\n        try:\n            return self._sender()\n\n        except TypeError:\n            return None\n\n\nclass ClassSignal:\n    \"\"\"\n    The class signal allows a signal to be set on a class rather than an instance.\n    This emulates the behavior of a PyQt signal\n    \"\"\"\n    _map = {}\n\n    def __get__(self, instance, owner):\n        if instance is None:\n            # When we access ClassSignal element on the class object without any instance,\n            # we return the ClassSignal itself\n            return self\n        tmp = self._map.setdefault(self, weakref.WeakKeyDictionary())\n        return tmp.setdefault(instance, Signal())\n\n    def __set__(self, instance, value):\n        raise RuntimeError(\"Cannot assign to a Signal object\")\n\n\nclass SignalFactory(dict):\n    \"\"\"\n    The Signal Factory object lets you handle signals by a string based name instead of by objects.\n    \"\"\"\n\n    def register(self, name, *slots):\n        \"\"\"\n        Registers a given signal\n        :param name: the signal to register\n        \"\"\"\n        # setdefault initializes the object even if it exists. This is more efficient\n        if name not in self:\n            self[name] = Signal()\n\n        for slot in slots:\n            self[name].connect(slot)\n\n    def deregister(self, name):\n        \"\"\"\n        Removes a given signal\n        :param name: the signal to deregister\n        \"\"\"\n        self.pop(name, None)\n\n    def emit(self, signalName, *args, **kwargs):\n        \"\"\"\n        Emits a signal by name if it exists. Any additional args or kwargs are passed to the signal\n        :param signalName: the signal name to emit\n        \"\"\"\n        assert signalName in self, f\"{signalName} is not a registered signal\"\n        self[signalName].emit(*args, **kwargs)\n\n    def connect(self, signalName, slot):\n        \"\"\"\n        Connects a given signal to a given slot\n        :param signalName: the signal name to connect to\n        :param slot: the callable slot to register\n        \"\"\"\n        assert signalName in self, f\"{signalName} is not a registered signal\"\n        self[signalName].connect(slot)\n\n    def block(self, signals=None, isBlocked=True):\n        \"\"\"\n        Sets the block on any provided signals, or to all signals\n\n        :param signals: defaults to all signals. Accepts either a single string or a list of strings\n        :param isBlocked: the state to set the signal to\n        \"\"\"\n        if signals:\n            try:\n                if isinstance(signals, basestring):\n                    signals = [signals]\n            except NameError:\n                if isinstance(signals, str):\n                    signals = [signals]\n\n        signals = signals or self.keys()\n\n        for signal in signals:\n            if signal not in self:\n                raise RuntimeError(f\"Could not find signal matching {signal}\")\n            self[signal].block(isBlocked)\n\n\nclass ClassSignalFactory:\n    \"\"\"\n    The class signal allows a signal factory to be set on a class rather than an instance.\n    \"\"\"\n    _map = {}\n    _names = set()\n\n    def __get__(self, instance, owner):\n        tmp = self._map.setdefault(self, weakref.WeakKeyDictionary())\n\n        signal = tmp.setdefault(instance, SignalFactory())\n        for name in self._names:\n            signal.register(name)\n\n        return signal\n\n    def __set__(self, instance, value):\n        raise RuntimeError(\"Cannot assign to a Signal object\")\n\n    def register(self, name):\n        \"\"\"\n        Registers a new signal with the given name\n        :param name: The signal to register\n        \"\"\"\n        self._names.add(name)\n"
  },
  {
    "path": "meshroom/common/__init__.py",
    "content": "\"\"\"\nThis module provides an abstraction around standard non-gui Qt notions (like Signal/Slot),\nso it can be used in python-only without the dependency to Qt.\n\nWarning: A call to `init(Backend.XXX)` is required to choose the backend before using this module.\n\"\"\"\n\nfrom enum import Enum\n\n\nclass Backend(Enum):\n    STANDALONE = 1\n    PYSIDE = 2\n\n\nDictModel = None\nListModel = None\nSlot = None\nSignal = None\nProperty = None\nBaseObject = None\nVariant = None\nVariantList = None\nJSValue = None\n\n\ndef init(backend):\n    global DictModel, ListModel, Slot, Signal, Property, BaseObject, Variant, VariantList, JSValue\n    if backend == Backend.PYSIDE:\n        # PySide types\n        from .qt import DictModel, ListModel, Slot, Signal, Property, BaseObject, Variant, VariantList, JSValue\n    elif backend == Backend.STANDALONE:\n        # Core types\n        from .core import DictModel, ListModel, Slot, Signal, Property, BaseObject, Variant, VariantList, JSValue\n\n\ndef strtobool(val: str):\n    \"\"\"Convert a string representation of truth to true (1) or false (0).\n\n    True values are 'y', 'yes', 't', 'true', 'on', and '1'; false values\n    are 'n', 'no', 'f', 'false', 'off', and '0'.  Raises ValueError if\n    'val' is anything else.\n    \"\"\"\n    val = val.lower()\n    if val in ('y', 'yes', 't', 'true', 'on', '1'):\n        return 1\n    elif val in ('n', 'no', 'f', 'false', 'off', '0'):\n        return 0\n    else:\n        raise ValueError(\"invalid truth value %r\" % (val,))\n\n\n# Default initialization\ninit(Backend.STANDALONE)\n"
  },
  {
    "path": "meshroom/common/core.py",
    "content": "from . import PySignal\n\n\nclass CoreDictModel:\n\n    def __init__(self, keyAttrName, **kwargs):\n        self._objects = {}\n        self._keyAttrName = keyAttrName\n\n    def __len__(self):\n        return len(self._objects)\n\n    def __bool__(self):\n        return bool(self._objects)\n\n    def __iter__(self):\n        \"\"\" Enables iteration over the list of objects. \"\"\"\n        return iter(self._objects.values())\n\n    def keys(self):\n        return self._objects.keys()\n\n    def items(self):\n        return self._objects.items()\n\n    def values(self):\n        return self._objects.values()\n\n    @property\n    def objects(self):\n        return self._objects\n\n    def get(self, key):\n        \"\"\"\n        :param key:\n        :return: the value or None if not found\n        \"\"\"\n        return self._objects.get(key)\n\n    def getr(self, key):\n        \"\"\"\n        Get or raise an error if the key does not exists.\n        :param key:\n        :return: the value\n        \"\"\"\n        return self._objects[key]\n\n    def add(self, obj):\n        key = getattr(obj, self._keyAttrName, None)\n        assert key is not None\n        assert key not in self._objects\n        self._objects[key] = obj\n\n    def rename(self, oldKey: str, newKey: str):\n        \"\"\" Rename an element in the dict model\n\n        Args:\n            oldKey (str): Previous key name of the element to replace.\n            newKey (str): New key name to insert in the model.\n\n        Raises:\n            KeyError: if the new name is already used.\n        \"\"\"\n        if newKey in self._objects.keys():\n            raise KeyError(f\"Key {newKey} is already in use in {self}\")\n        obj = self._objects[oldKey]\n        self._objects[newKey] = obj\n        del self._objects[oldKey]\n\n    def pop(self, key):\n        assert key in self._objects\n        return self._objects.pop(key)\n\n    def remove(self, obj):\n        assert obj in self._objects.values()\n        del self._objects[getattr(obj, self._keyAttrName)]\n\n    def clear(self):\n        self._objects.clear()\n\n    def update(self, objects):\n        for obj in objects:\n            self.add(obj)\n\n    def reset(self, objects):\n        self.clear()\n        self.update(objects)\n\n\nclass CoreListModel:\n    def __init__(self, parent=None):\n        self._objects = []\n\n    def __iter__(self):\n        return self._objects.__iter__()\n\n    def __len__(self):\n        return len(self._objects)\n\n    def __getitem__(self, idx):\n        return self._objects[idx]\n\n    def values(self):\n        return self._objects\n\n    def setObjectList(self, iterable):\n        self.clear()\n        self._objects = iterable\n\n    def at(self, idx):\n        return self._objects[idx]\n\n    def append(self, obj):\n        self._objects.append(obj)\n\n    def extend(self, iterable):\n        self._objects.extend(iterable)\n\n    def indexOf(self, obj):\n        return self._objects.index(obj)\n\n    def removeAt(self, idx, count=1):\n        del self._objects[idx:idx+count]\n\n    def remove(self, obj):\n        self._objects.remove(obj)\n\n    def clear(self):\n        self._objects = []\n\n    def insert(self, index, iterable):\n        self._objects[index:index] = iterable\n\n\ndef CoreSlot(*args, **kwargs):\n    def slot_decorator(func):\n        def func_wrapper(*f_args, **f_kwargs):\n            return func(*f_args, **f_kwargs)\n        return func_wrapper\n    return slot_decorator\n\n\nclass CoreProperty(property):\n    def __init__(self, ptype, fget=None, fset=None, **kwargs):\n        super().__init__(fget, fset)\n\n\nclass CoreObject:\n\n    def __init__(self, parent=None, *args, **kwargs):\n        super().__init__()\n        self._parent = parent\n        # Note: we do not use ClassSignal, as it can not be used in __del__.\n        self.destroyed = PySignal.Signal()\n\n    def __del__(self):\n        self.destroyed.emit()\n\n    def parent(self):\n        return self._parent\n\n\nDictModel = CoreDictModel\nListModel = CoreListModel\nSlot = CoreSlot\nSignal = PySignal.ClassSignal\nProperty = CoreProperty\nBaseObject = CoreObject\nVariant = object\nVariantList = object\nJSValue = None\n"
  },
  {
    "path": "meshroom/common/deprecated.py",
    "content": "\"\"\"Utilities for marking function parameters as deprecated.\"\"\"\n\nimport warnings\nimport logging\n\n\ndef depreciateParam(paramToDepreciate, msg):\n    \"\"\"Decorator factory that emits a deprecation warning when a specific keyword argument is used.\n\n    Use this to gracefully phase out function parameters by warning callers\n    that a particular keyword argument is deprecated, while still allowing\n    the decorated function to execute normally.\n\n    Args:\n        paramToDepreciate (str): The name of the keyword argument to flag as deprecated.\n        msg (str): A warning message template that will be formatted with the keyword\n            arguments passed to the decorated function (using ``str.format(**kwargs)``).\n\n    Returns:\n        callable: A decorator that wraps the target function with deprecation checks.\n\n    Example:\n        >>> @depreciateParam(\"oldArg\", \"'{oldArg}' is deprecated, use 'newArg' instead\")\n        ... def my_func(newArg=None, oldArg=None):\n        ...     pass\n        >>> my_func(oldArg=\"value\")  # emits DeprecationWarning\n    \"\"\"\n    def decorator(function):\n        def wrapper(*args, **kwargs):\n            if paramToDepreciate in kwargs.keys():\n                warnings.warn(msg.format(**kwargs), DeprecationWarning)\n                logging.warn(DeprecationWarning(msg.format(**kwargs)))\n            return function(*args, **kwargs)\n        return wrapper\n    return decorator\n"
  },
  {
    "path": "meshroom/common/qt.py",
    "content": "from PySide6 import QtCore, QtQml\nimport shiboken6\n\n\nclass QObjectListModel(QtCore.QAbstractListModel):\n    \"\"\"\n    QObjectListModel provides a more powerful, but still easy to use, alternative to using\n    QObjectList lists as models for QML views. As a QAbstractListModel, it has the ability to\n    automatically notify the view of specific changes to the list, such as adding or removing\n    items. At the same time it provides QList-like convenience functions such as append, at,\n    and removeAt for easily working with the model from Python.\n    \"\"\"\n    ObjectRole = QtCore.Qt.UserRole\n\n    def __init__(self, keyAttrName='', parent=None):\n        \"\"\" Constructs an object list model with the given parent. \"\"\"\n        super().__init__(parent)\n\n        self._objects = list()      # Internal list of objects\n        self._keyAttrName = keyAttrName\n        self._objectByKey = {}\n        self.roles = QtCore.QAbstractListModel.roleNames(self)\n        self.roles[self.ObjectRole] = b\"object\"\n\n        self.requestDeletion.connect(self.onRequestDeletion, QtCore.Qt.QueuedConnection)\n\n    def roleNames(self):\n        return self.roles\n\n    def __iter__(self):\n        \"\"\" Enables iteration over the list of objects. \"\"\"\n        return iter(self._objects)\n\n    def keys(self):\n        return self._objectByKey.keys()\n\n    def items(self):\n        return self._objectByKey.items()\n\n    def __len__(self):\n        return self.size()\n\n    def __bool__(self):\n        return self.size() > 0\n\n    def __getitem__(self, index):\n        \"\"\" Enables the [] operator.\n        Only accepts index (integer).\n        \"\"\"\n        return self._objects[index]\n\n    def data(self, index, role):\n        \"\"\" Returns data for the specified role, from the item with the\n        given index. The only valid role is ObjectRole.\n\n        If the view requests an invalid index or role, an invalid variant\n        is returned.\n        \"\"\"\n        if index.row() < 0 or index.row() >= len(self._objects):\n            return None\n        return self._objects[index.row()]\n\n    def rowCount(self, parent):\n        \"\"\" Returns the number of rows in the model. This value corresponds to the\n        number of items in the model's internal object list.\n        \"\"\"\n        return self.size()\n\n    def objectList(self):\n        \"\"\" Returns the object list used by the model to store data. \"\"\"\n        return self._objects\n\n    def values(self):\n        return self._objects\n\n    def setObjectList(self, objects):\n        \"\"\" Sets the model's internal objects list to objects. The model will\n        notify any attached views that its underlying data has changed.\n        \"\"\"\n        oldSize = self.size()\n        self.beginResetModel()\n        for obj in self._objects:\n            self._dereferenceItem(obj)\n        self._objects = objects\n        for obj in self._objects:\n            self._referenceItem(obj)\n        self.endResetModel()\n        self.dataChanged.emit(self.index(0), self.index(self.size() - 1), [])\n        if self.size() != oldSize:\n            self.countChanged.emit()\n\n    # ######\n    # BaseModel API\n    # ######\n    @property\n    def objects(self):\n        return self._objectByKey\n\n    @QtCore.Slot(str, result=QtCore.QObject)\n    def get(self, key):\n        \"\"\"\n        :param key:\n        :return: the value or None if not found\n        \"\"\"\n        return self._objectByKey.get(key)\n\n    @QtCore.Slot(str, result=QtCore.QObject)\n    def getr(self, key):\n        \"\"\"\n        Get or raise an error if the key does not exists.\n        :param key:\n        :return: the value\n        \"\"\"\n        return self._objectByKey[key]\n\n    def add(self, obj):\n        self.append(obj)\n\n    def pop(self, key):\n        obj = self.get(key)\n        self.remove(obj)\n        return obj\n\n    ############\n    # List API #\n    ############\n    @QtCore.Slot(QtCore.QObject)\n    def append(self, obj):\n        \"\"\" Insert object at the end of the model. \"\"\"\n        self.extend([obj])\n\n    def extend(self, iterable):\n        \"\"\" Insert objects at the end of the model. \"\"\"\n        self.beginInsertRows(QtCore.QModelIndex(), self.size(), self.size() + len(iterable) - 1)\n        [self._referenceItem(obj) for obj in iterable]\n        self._objects.extend(iterable)\n        self.endInsertRows()\n        self.countChanged.emit()\n\n    def insert(self, i, toInsert):\n        \"\"\"  Inserts object(s) at index position i in the model and notifies\n        any views. If i is 0, the object is prepended to the model. If i\n        is size(), the object is appended to the list.\n        Accepts both QObject and list of QObjects.\n        \"\"\"\n        if not isinstance(toInsert, list):\n            toInsert = [toInsert]\n        self.beginInsertRows(QtCore.QModelIndex(), i, i + len(toInsert) - 1)\n        for obj in reversed(toInsert):\n            self._referenceItem(obj)\n            self._objects.insert(i, obj)\n        self.endInsertRows()\n        self.countChanged.emit()\n\n    @QtCore.Slot(int, result=QtCore.QObject)\n    def at(self, i):\n        \"\"\" Return the object at index i. \"\"\"\n        return self._objects[i]\n\n    def replace(self, i, obj):\n        \"\"\" Replaces the item at index position i with object and\n        notifies any views. i must be a valid index position in the list\n        (i.e., 0 <= i < size()).\n        \"\"\"\n        self._dereferenceItem(self._objects[i])\n        self._referenceItem(obj)\n        self._objects[i] = obj\n        self.dataChanged.emit(self.index(i), self.index(i), [])\n\n    def rename(self, oldKey: str, newKey: str):\n        \"\"\" Rename an element in the model\n\n        Args:\n            oldKey (str): Previous key name of the element to replace.\n            newKey (str): New key name to insert in the model.\n\n        Raises:\n            KeyError: if the new name is already used.\n        \"\"\"\n        if newKey in self._objectByKey.keys():\n            raise KeyError(f\"Key {newKey} is already in use in {self}\")\n        obj = self._objectByKey[oldKey]\n        index = self.indexOf(obj)\n        self._objectByKey[newKey] = obj\n        del self._objectByKey[oldKey]\n        self.dataChanged.emit(self.index(index), self.index(index), [])\n\n    def move(self, fromIndex, toIndex):\n        \"\"\" Moves the item at index position from to index position to\n        and notifies any views.\n        This function assumes that both from and to are at least 0 but less than\n        size(). To avoid failure, test that both from and to are at\n        least 0 and less than size().\n        \"\"\"\n        value = toIndex\n        if toIndex > fromIndex:\n            value += 1\n        if not self.beginMoveRows(QtCore.QModelIndex(), fromIndex, fromIndex, QtCore.QModelIndex(), value):\n            return\n        self._objects.insert(toIndex, self._objects.pop(fromIndex))\n        self.endMoveRows()\n\n    def removeAt(self, i, count=1):\n        \"\"\"  Removes count number of items from index position i and notifies any views.\n        i must be a valid index position in the model (i.e., 0 <= i < size()), as\n        must as i + count - 1.\n        \"\"\"\n        self.beginRemoveRows(QtCore.QModelIndex(), i, i + count - 1)\n        for cpt in range(count):\n            obj = self._objects.pop(i)\n            self._dereferenceItem(obj)\n        self.endRemoveRows()\n        self.countChanged.emit()\n\n    @QtCore.Slot(QtCore.QObject)\n    def remove(self, obj):\n        \"\"\" Removes the first occurrence of the given object. Raises a ValueError if not in list. \"\"\"\n        if not self.contains(obj):\n            raise ValueError(\"QObjectListModel.remove(obj) : obj not in list\")\n        self.removeAt(self.indexOf(obj))\n\n    def takeAt(self, i):\n        \"\"\"  Removes the item at index position i (notifying any views) and returns it.\n        i must be a valid index position in the model (i.e., 0 <= i < size()).\n        \"\"\"\n        self.beginRemoveRows(QtCore.QModelIndex(), i, i)\n        obj = self._objects.pop(i)\n        self._dereferenceItem(obj)\n        self.endRemoveRows()\n        self.countChanged.emit()\n        return obj\n\n    def clear(self):\n        \"\"\" Removes all items from the model and notifies any views. \"\"\"\n        if not self._objects:\n            return\n        self.beginResetModel()\n        for obj in self._objects:\n            self._dereferenceItem(obj)\n        self._objects = []\n        self.endResetModel()\n        self.countChanged.emit()\n\n    def update(self, objects):\n        self.extend(objects)\n\n    def reset(self, objects):\n        self.setObjectList(objects)\n\n    @QtCore.Slot(QtCore.QObject, result=bool)\n    def contains(self, obj):\n        \"\"\" Returns true if the list contains an occurrence of object;\n        otherwise returns false.\n        \"\"\"\n        return obj in self._objects\n\n    @QtCore.Slot(QtCore.QObject, result=int)\n    def indexOf(self, matchObj, fromIndex=0, positive=True):\n        \"\"\" Returns the index position of the first occurrence of object in\n        the model, searching forward from index position from.\n        If positive is True, will always return a positive index.\n        \"\"\"\n        index = self._objects[fromIndex:].index(matchObj) + fromIndex\n        if positive and index < 0:\n            index += self.size()\n        return index\n\n    def lastIndexOf(self, matchObj, fromIndex=-1, positive=True):\n        \"\"\"    Returns the index position of the last occurrence of object in\n        the list, searching backward from index position from. If\n        from is -1 (the default), the search starts at the last item.\n        If positive is True, will always return a positive index.\n        \"\"\"\n        r = list(self._objects)\n        r.reverse()\n        index = - r[-fromIndex - 1:].index(matchObj) + fromIndex\n        if positive and index < 0:\n            index += self.size()\n        return index\n\n    def size(self):\n        \"\"\" Returns the number of items in the model. \"\"\"\n        return len(self._objects)\n\n    @QtCore.Slot(result=bool)\n    def isEmpty(self):\n        \"\"\" Returns true if the model contains no items; otherwise returns false. \"\"\"\n        return len(self._objects) == 0\n\n    def _referenceItem(self, item):\n        if not item.parent():\n            # Take ownership of the object if not already parented\n            item.setParent(self)\n        if not self._keyAttrName:\n            return\n        key = getattr(item, self._keyAttrName, None)\n        if key is None:\n            return\n        if key in self._objectByKey:\n            raise ValueError(f\"Object key {self._keyAttrName}:{key} is not unique\")\n\n        self._objectByKey[key] = item\n\n    @QtCore.Slot(int, result=QtCore.QModelIndex)\n    def index(self, row: int, column: int = 0, parent=QtCore.QModelIndex()):\n        \"\"\" Returns the model index for the given row, column and parent index. \"\"\"\n        if parent.isValid() or column != 0:\n            return QtCore.QModelIndex()\n        if row < 0 or row >= self.size():\n            return QtCore.QModelIndex()\n        return self.createIndex(row, column, self._objects[row])\n\n    def _dereferenceItem(self, item):\n        # Ask for object deletion if parented to the model\n        if shiboken6.isValid(item) and item.parent() == self:\n            # delay deletion until the next event loop\n            # This avoids warnings when the QML engine tries to evaluate (but should not)\n            # an object that has already been deleted\n            self.requestDeletion.emit(item)\n\n        if not self._keyAttrName:\n            return\n        key = getattr(item, self._keyAttrName, None)\n        if key is None:\n            return\n        if key not in self._objectByKey:\n            raise RuntimeError(f\"{key} is not in the Model: {self._objectByKey.keys()}\")\n        del self._objectByKey[key]\n\n    def onRequestDeletion(self, item):\n        item.deleteLater()\n\n    countChanged = QtCore.Signal()\n    count = QtCore.Property(int, size, notify=countChanged)\n\n    requestDeletion = QtCore.Signal(QtCore.QObject)\n\n\nclass QTypedObjectListModel(QObjectListModel):\n    \"\"\" Typed QObjectListModel that exposes T properties as roles \"\"\"\n    # TODO: handle notify signal to emit dataChanged signal\n\n    def __init__(self, keyAttrName=\"name\", T=QtCore.QObject, parent=None):\n        super().__init__(keyAttrName, parent)\n\n        self._T = T\n        blacklist = [\"id\", \"index\", \"class\", \"model\", \"modelData\"]\n\n        self._metaObject = T.staticMetaObject\n        propCount = self._metaObject.propertyCount()\n\n        role = self.ObjectRole + 1\n        for i in range(0, propCount):\n            prop = self._metaObject.property(i)\n            if not prop.name() in blacklist:\n                self.roles[role] = prop.name()\n                role += 1\n            else:\n                print(\"Reserved role name: \" + prop.name())\n\n    def data(self, index, role):\n        obj = super().data(index, self.ObjectRole)\n        if role == self.ObjectRole:\n            return obj\n        if obj:\n            return obj.property(self.roles[role])\n        return None\n\n    def roleForName(self, name):\n        roles = [role for role, value in self.roles.items() if value == name]\n        return roles[0] if roles else -1\n\n    def _referenceItem(self, item):\n        if item.staticMetaObject != self._metaObject:\n            raise TypeError(\"Invalid object type: expected {}, got {}\".format(\n                self._metaObject.className(), item.staticMetaObject.className()))\n        super()._referenceItem(item)\n\n\nclass SortedModelByReference(QtCore.QSortFilterProxyModel):\n    \"\"\" Sort a source model based on the ordered list (reference) of the same elements.\n    This proxy is useful if the model needs to be sorted a certain way for a specific use.\n    \"\"\"\n    def __init__(self, parent):\n        super().__init__(parent)\n        self._reference = []\n\n    def setReference(self, iterable):\n        \"\"\" Set the reference ordered list \"\"\"\n        self._reference = iterable\n        self.sort()\n\n    def reference(self):\n        return self._reference\n\n    def lessThan(self, left, right):\n        l = self.sourceModel().data(left, QObjectListModel.ObjectRole)\n        r = self.sourceModel().data(right, QObjectListModel.ObjectRole)\n        if l not in self._reference:\n            return False\n        if r not in self._reference:\n            return True\n        return self._reference.index(l) < self._reference.index(r)\n\n    def sort(self):\n        \"\"\" Sort the proxy and call invalidate() \"\"\"\n        super().sort(0, QtCore.Qt.AscendingOrder)\n        self.invalidate()\n\n\nDictModel = QObjectListModel\nListModel = QObjectListModel\nSlot = QtCore.Slot\nSignal = QtCore.Signal\nProperty = QtCore.Property\nBaseObject = QtCore.QObject\nVariant = \"QVariant\"\nVariantList = \"QVariantList\"\nJSValue = QtQml.QJSValue\n"
  },
  {
    "path": "meshroom/core/__init__.py",
    "content": "from contextlib import contextmanager\nimport hashlib\nimport importlib\nimport inspect\nimport logging\nimport os\nfrom pathlib import Path\nimport pkgutil\nimport sys\nimport traceback\nimport uuid\n\ntry:\n    # for cx_freeze\n    import encodings.ascii\n    import encodings.idna\n    import encodings.utf_8\nexcept Exception:\n    pass\n\nfrom meshroom.core.plugins import NodePlugin, NodePluginManager, Plugin, processEnvFactory, formatNodeDescriptionErrorMessage\nfrom meshroom.core.submitter import BaseSubmitter\nfrom meshroom.env import EnvVar, meshroomFolder\nfrom . import desc\nfrom .desc import MrNodeType\n\n# Setup logging\nlogging.basicConfig(format='[%(asctime)s][%(levelname)s] %(message)s', level=logging.INFO)\n\n# make a UUID based on the host ID and current time\nsessionUid = str(uuid.uuid1())\n\ncacheFolderName = 'MeshroomCache'\npluginManager: NodePluginManager = NodePluginManager()\nsubmitters: dict[str, BaseSubmitter] = {}\npipelineTemplates: dict[str, str] = {}\n\n\ndef hashValue(value) -> str:\n    \"\"\" Hash 'value' using sha1. \"\"\"\n    hashObject = hashlib.sha1(str(value).encode('utf-8'))\n    return hashObject.hexdigest()\n\n\n@contextmanager\ndef add_to_path(p):\n    import sys\n    old_path = sys.path\n    sys.path = sys.path[:]\n    sys.path.insert(0, p)\n    try:\n        yield\n    finally:\n        sys.path = old_path\n\n\ndef loadClasses(folder: str, packageName: str, classType: type) -> list[type]:\n    \"\"\"\n    Go over the Python module named \"packageName\" located in \"folder\" to find files\n    that contain classes of type \"classType\" and return these classes in a list.\n\n    Args:\n        folder: the folder to load the module from.\n        packageName: the name of the module to look for nodes in.\n        classType: the class to look for in the files that are inspected.\n    \"\"\"\n    classes = []\n    errors = []\n\n    resolvedFolder = str(Path(folder).resolve())\n    # temporarily add folder to python path\n    with add_to_path(resolvedFolder):\n        # import node package\n\n        try:\n            package = importlib.import_module(packageName)\n            packageName = package.packageName if hasattr(package, \"packageName\") \\\n                else package.__name__\n            packagePath = os.path.dirname(package.__file__)\n        except Exception as exc:\n            tb = traceback.extract_tb(exc.__traceback__)\n            last_call = tb[-1]\n            logging.warning(f'  * Failed to load package \"{packageName}\" from folder \"{resolvedFolder}\" ({type(exc).__name__}): {str(exc)}\\n'\n                            # filename:lineNumber functionName\n                            f'{last_call.filename}:{last_call.lineno} {last_call.name}\\n'\n                            # line of code with the error\n                            f'{last_call.line}'\n                            # Full traceback\n                            f'\\n{traceback.format_exc()}\\n\\n'\n                            )\n            return []\n\n        for _, pluginName, _ in pkgutil.iter_modules(package.__path__):\n            pluginModuleName = \".\" + pluginName\n\n            try:\n                pluginMod = importlib.import_module(pluginModuleName, package=package.__name__)\n                plugins = [plugin for _, plugin in inspect.getmembers(pluginMod, inspect.isclass)\n                           if plugin.__module__ == f\"{package.__name__}.{pluginName}\"\n                           and issubclass(plugin, classType)]\n\n                if not plugins:\n                    # Only packages/folders have __path__, single module/file do not have it.\n                    isPackage = hasattr(pluginMod, \"__path__\")\n                    # Sub-folders/Packages should not raise a warning\n                    if not isPackage:\n                        logging.warning(f\"No class defined in plugin: {package.__name__}.{pluginName} ('{pluginMod.__file__}')\")\n\n                for p in plugins:\n                    p.packageName = packageName\n                    p.packagePath = packagePath\n                    if classType == desc.BaseNode:\n                        nodePlugin = NodePlugin(p)\n                        if nodePlugin.errors:\n                            explicitErrors = []\n                            for err in nodePlugin.errors:\n                                explicitErrors.append(f\"\\n\\t - {formatNodeDescriptionErrorMessage(err)}\")\n                            errors.append(f\"  * {pluginName}: The following parameters have issues: {''.join(explicitErrors)}\")\n                        classes.append(nodePlugin)\n                    else:\n                        classes.append(p)\n            except Exception as exc:\n                if classType == BaseSubmitter:\n                    logging.warning(f\" Could not load submitter {pluginName} from package '{package.__name__}'\\n{exc}\")\n                else:\n                    tb = traceback.extract_tb(exc.__traceback__)\n                    last_call = tb[-1]\n                    errors.append(f'  * {pluginName} ({type(exc).__name__}): {exc}\\n'\n                                # filename:lineNumber functionName\n                                f'{last_call.filename}:{last_call.lineno} {last_call.name}\\n'\n                                # line of code with the error\n                                f'{last_call.line}'\n                                # Full traceback\n                                f'\\n{traceback.format_exc()}\\n\\n'\n                                )\n\n    if errors:\n        logging.warning(' The following \"{package}\" plugins could not be loaded:\\n'\n                        '{errorMsg}\\n'\n                        .format(package=packageName, errorMsg='\\n'.join(errors)))\n\n    return classes\n\n\ndef loadClassesNodes(folder: str, packageName: str) -> list[NodePlugin]:\n    \"\"\"\n    Return the list of all the NodePlugins that were created following the search of the\n    Python module named \"packageName\" located in the folder \"folder\".\n    A NodePlugin is created when a file within \"packageName\" that contains a class inheriting\n    desc.BaseNode is found.\n\n    Args:\n        folder: the folder to load the module from.\n        packageName: the name of the module to look for nodes in.\n\n    Returns:\n        list[NodePlugin]: a list of all the NodePlugins that were created based on the\n                          module's search. If none has been created, an empty list is returned.\n    \"\"\"\n    return loadClasses(folder, packageName, desc.BaseNode)\n\n\ndef loadClassesSubmitters(folder: str, packageName: str) -> list[BaseSubmitter]:\n    \"\"\"\n    Return the list of all the submitters that were found during the search of the\n    Python module named \"packageName\" that located in the folder \"folder\".\n    A submitter is found if a file within \"packageName\" contains a class inheriting\n    from BaseSubmitter.\n\n    Args:\n        folder: the folder to load the module from.\n        packageName: the name of the module to look for nodes in.\n\n    Returns:\n        list[BaseSubmitter]: a list of all the submitters that were found during the\n                             module's search\n    \"\"\"\n    return loadClasses(folder, packageName, BaseSubmitter)\n\n\nclass Version:\n    \"\"\"\n    Version provides convenient properties and methods to manipulate and compare versions.\n    \"\"\"\n\n    def __init__(self, *args):\n        \"\"\"\n        Args:\n            *args (convertible to int): version values\n        \"\"\"\n        if len(args) == 0:\n            self.components = tuple()\n            self.status = ''\n        elif len(args) == 1:\n            versionName = args[0]\n            if isinstance(versionName, str):\n                self.components, self.status = Version.toComponents(versionName)\n            elif isinstance(versionName, (list, tuple)):\n                self.components = tuple([int(v) for v in versionName])\n                self.status = ''\n            else:\n                raise RuntimeError(\"Version: Unsupported input type.\")\n        else:\n            self.components = tuple([int(v) for v in args])\n            self.status = ''\n\n    def __repr__(self):\n        return self.name\n\n    def __neg__(self):\n        return not self.name\n\n    def __len__(self):\n        return len(self.components)\n\n    def __eq__(self, other):\n        \"\"\"\n        Test equality between 'self' with 'other'.\n\n        Args:\n            other (Version): the version to compare to\n\n        Returns:\n            bool: whether the versions are equal\n        \"\"\"\n        return self.name == other.name\n\n    def __lt__(self, other):\n        \"\"\"\n        Test 'self' inferiority to 'other'.\n\n        Args:\n            other (Version): the version to compare to\n\n        Returns:\n            bool: whether self is inferior to other\n        \"\"\"\n        return self.components < other.components\n\n    def __le__(self, other):\n        \"\"\"\n        Test 'self' inferiority or equality to 'other'.\n\n        Args:\n            other (Version): the version to compare to\n\n        Returns:\n            bool: whether self is inferior or equal to other\n        \"\"\"\n        return self.components <= other.components\n\n    @staticmethod\n    def toComponents(versionName):\n        \"\"\"\n        Split 'versionName' as a tuple of individual components, including its status if\n        there is any.\n\n        Args:\n            versionName (str): version name\n\n        Returns:\n            tuple of int, string: split version numbers, status if any (or empty string)\n        \"\"\"\n        if not versionName:\n            return (), ''\n\n        status = ''\n        # If there is a status, it is placed after a \"-\" (up to Meshroom 2025.1.0) or a \"+\"\n        versionName = versionName.replace(\"-\", \"+\")  # Keep compatibility for scenes created with 2025.1.0 or older\n        splitComponents = versionName.split(\"+\", maxsplit=1)\n        # If there is no status, splitComponents is equal to [versionName]\n        if len(splitComponents) > 1:\n            status = splitComponents[-1]\n        return tuple([int(v) for v in splitComponents[0].split(\".\")]), status\n\n    @property\n    def name(self):\n        \"\"\" Version major number. \"\"\"\n        return \".\".join([str(v) for v in self.components])\n\n    @property\n    def major(self):\n        \"\"\" Version major number. \"\"\"\n        return self.components[0]\n\n    @property\n    def minor(self):\n        \"\"\" Version minor number. \"\"\"\n        if len(self) < 2:\n            return 0\n        return self.components[1]\n\n    @property\n    def micro(self):\n        \"\"\" Version micro number. \"\"\"\n        if len(self) < 3:\n            return 0\n        return self.components[2]\n\n\ndef moduleVersion(moduleName: str, default=None):\n    \"\"\" Return the version of a module indicated with '__version__' keyword.\n\n    Args:\n        moduleName (str): the name of the module to get the version of\n        default: the value to return if no version info is available\n\n    Returns:\n        str: the version of the module\n    \"\"\"\n    return getattr(sys.modules[moduleName], \"__version__\", default)\n\n\ndef nodeVersion(nodeDesc: desc.Node, default=None):\n    \"\"\" Return node type version for the given node description class.\n\n    Args:\n        nodeDesc (desc.Node): the node description class\n        default: the value to return if no version info is available\n\n    Returns:\n        str: the version of the node type\n    \"\"\"\n    return moduleVersion(nodeDesc.__module__, default)\n\n\ndef loadNodes(folder, packageName) -> list[NodePlugin]:\n    if not os.path.isdir(folder):\n        logging.error(f\"Node folder '{folder}' does not exist.\")\n        return []\n\n    nodes = loadClassesNodes(folder, packageName)\n    return nodes\n\n\ndef loadAllNodes(folder) -> list[Plugin]:\n    plugins = []\n    for _, package, ispkg in pkgutil.iter_modules([folder]):\n        if ispkg:\n            plugin = Plugin(package, folder)\n            nodePlugins = loadNodes(folder, package)\n            if nodePlugins:\n                for node in nodePlugins:\n                    plugin.addNodePlugin(node)\n                nodesStr = ', '.join([node.nodeDescriptor.__name__ for node in nodePlugins])\n                logging.debug(f'Nodes loaded [{package}]: {nodesStr}')\n            plugins.append(plugin)\n    return plugins\n\n\ndef loadPluginFolder(folder) -> list[Plugin]:\n    if not os.path.isdir(folder):\n        logging.info(f\"Plugin folder '{folder}' does not exist.\")\n        return\n\n    mrFolder = Path(folder, 'meshroom')\n    if not mrFolder.exists():\n        logging.info(f\"Plugin folder '{folder}' does not contain a 'meshroom' folder.\")\n        return\n\n    plugins = loadAllNodes(folder=mrFolder)\n    if plugins:\n        for plugin in plugins:\n            pluginManager.addPlugin(plugin)\n            pipelineTemplates.update(plugin.templates)\n\n    return plugins\n\n\ndef loadPluginsFolder(folder):\n    if not os.path.isdir(folder):\n        logging.debug(f\"PluginSet folder '{folder}' does not exist.\")\n        return\n\n    for file in os.listdir(folder):\n        if os.path.isdir(file):\n            subFolder = os.path.join(folder, file)\n            loadPluginFolder(subFolder)\n\n\ndef registerSubmitter(s: BaseSubmitter):\n    if s.name in submitters:\n        logging.error(f\"Submitter {s.name} is already registered.\")\n    submitters[s.name] = s\n\n\ndef loadSubmitters(folder, packageName) -> list[BaseSubmitter]:\n    if not os.path.isdir(folder):\n        logging.error(f\"Submitters folder '{folder}' does not exist.\")\n        return\n\n    return loadClassesSubmitters(folder, packageName)\n\n\ndef loadAllSubmitters(folder) -> list[BaseSubmitter]:\n    submitters = []\n    for _, package, ispkg in pkgutil.iter_modules([folder]):\n        if ispkg:\n            subs = loadSubmitters(folder, package)\n            if subs:\n                submitters.extend(subs)\n    return submitters\n\n\ndef loadPipelineTemplates(folder: str):\n    if not os.path.isdir(folder):\n        logging.error(f\"Pipeline templates folder '{folder}' does not exist.\")\n        return\n    for file in os.listdir(folder):\n        if file.endswith(\".mg\") and file not in pipelineTemplates:\n            pipelineTemplates[os.path.splitext(file)[0]] = os.path.join(folder, file)\n\n\ndef initNodes():\n    additionalNodesPath = EnvVar.getList(EnvVar.MESHROOM_NODES_PATH)\n    nodesFolders = [os.path.join(meshroomFolder, \"nodes\")] + additionalNodesPath\n    for f in nodesFolders:\n        plugins = loadAllNodes(folder=f)\n        if plugins:\n            for plugin in plugins:\n                pluginManager.addPlugin(plugin)\n\n\ndef initSubmitters():\n    \"\"\" Detect and register submitter plugins\n    Note: Make sure the package name (folder inside the additionalPaths folders)\n          are unique : so we cannot name them \"submitters\" because it is already taken\n          by the submitters package inside meshroom\n    \"\"\"\n    # Load submitters\n    submitterPaths = EnvVar.getList(EnvVar.MESHROOM_SUBMITTERS_PATH)\n    for folder in submitterPaths:\n        subs = loadAllSubmitters(folder)\n        for sub in subs:\n            registerSubmitter(sub())\n\n\ndef initPipelines():\n    # Load pipeline templates: check in the default folder and any folder the user might have\n    # added to the environment variable\n    pipelineTemplatesFolders = EnvVar.getList(EnvVar.MESHROOM_PIPELINE_TEMPLATES_PATH)\n    for f in pipelineTemplatesFolders:\n        loadPipelineTemplates(f)\n    for plugin in pluginManager.getPlugins().values():\n        pipelineTemplates.update(plugin.templates)\n\n\ndef initPlugins():\n    # Classic plugins (with a DirTreeProcessEnv)\n    additionalPluginsPath = EnvVar.getList(EnvVar.MESHROOM_PLUGINS_PATH)\n    pluginsFolders = [os.path.join(meshroomFolder, \"plugins\")] + additionalPluginsPath\n    for f in pluginsFolders:\n        plugins = loadPluginFolder(folder=f)\n        # Set the ProcessEnv for each plugin\n        if plugins:\n            for plugin in plugins:\n                plugin.processEnv = processEnvFactory(f, plugin.configEnv)\n\n    # Rez plugins (with a RezProcessEnv)\n    rezPlugins = initRezPlugins()\n\n\ndef initRezPlugins():\n    rezPlugins = {}\n    rezList = EnvVar.getList(EnvVar.MESHROOM_REZ_PLUGINS)\n\n    for p in rezList:\n        name, path = p.split(\"=\")\n        rezPlugins[name] = path  # \"name\" is the name of the Rez package\n        plugins = loadPluginFolder(folder=path)\n        # Set the ProcessEnv for Rez plugins\n        if plugins:\n            for plugin in plugins:\n                plugin.processEnv = processEnvFactory(path, plugin.configEnv, envType=\"rez\", uri=name)\n\n    return rezPlugins\n"
  },
  {
    "path": "meshroom/core/attribute.py",
    "content": "#!/usr/bin/env python\nfrom __future__ import annotations\n\nimport copy\nimport os\nimport re\nimport weakref\nimport logging\nimport inspect\n\nfrom collections.abc import Iterable, Sequence\nfrom string import Template\nfrom meshroom.common import BaseObject, Property, Variant, Signal, ListModel, DictModel, Slot\nfrom meshroom.core import desc, hashValue\nfrom meshroom.core.keyValues import KeyValues\nfrom meshroom.core.exception import InvalidEdgeError\n\nfrom typing import TYPE_CHECKING, Optional\n\nif TYPE_CHECKING:\n    from meshroom.core.graph import Edge\n\n\ndef attributeFactory(description: str, value, isOutput: bool, node, root=None, parent=None):\n    \"\"\"\n    Create an Attribute based on description type.\n\n    Args:\n        description: the Attribute description\n        value: value of the Attribute. Will be set if not None.\n        isOutput: whether the Attribute is an output attribute.\n        node (Node): node owning the Attribute. Note that the created Attribute is not added to \\\n                     Node's attributes\n        root: (optional) parent Attribute (must be ListAttribute or GroupAttribute)\n        parent (BaseObject): (optional) the parent BaseObject if any\n    \"\"\"\n    attr: Attribute = description.instanceType(node, description, isOutput, root, parent)\n    if value is not None:\n        attr._setValue(value)\n    else:\n        attr.resetToDefaultValue()\n    # Only connect slot that reacts to value change once initial value has been set.\n    # NOTE: This should be handled by the Node class, but we are currently limited by our core\n    #       signal implementation that does not support emitting parameters.\n    #       And using a lambda here to send the attribute as a parameter causes\n    #       performance issues when using the pyside backend.\n    attr.valueChanged.connect(attr._onValueChanged)\n    return attr\n\n\nclass Attribute(BaseObject):\n    \"\"\"\n    \"\"\"\n    LINK_EXPRESSION_REGEX = re.compile(r'^\\{[A-Za-z]+[A-Za-z0-9_.\\[\\]]*\\}$')\n    VALID_IMAGE_SEMANTICS = [\"image\", \"imageList\", \"sequence\"]\n    VALID_3D_EXTENSIONS = [\".obj\", \".stl\", \".fbx\", \".gltf\", \".abc\", \".ply\"]\n    VALID_TEXT_EXTENSIONS = [\".txt\", \".json\", \".log\", \".csv\", \".md\"]\n\n    @staticmethod\n    def isLinkExpression(value) -> bool:\n        \"\"\"\n        Return whether the given argument is a link expression.\n        A link expression is a string matching the {nodeName.attrName} pattern.\n        \"\"\"\n        return isinstance(value, str) and Attribute.LINK_EXPRESSION_REGEX.match(value)\n\n    def __init__(self, node, attributeDesc: desc.Attribute, isOutput: bool, root=None, parent=None):\n        \"\"\"\n        Attribute constructor\n\n        Args:\n            node (Node): the Node hosting this Attribute\n            attributeDesc: the description of this Attribute\n            isOutput: whether this Attribute is an output of the Node\n            root (Attribute): (optional) the root Attribute (List or Group) containing this one\n            parent (BaseObject): (optional) the parent BaseObject\n        \"\"\"\n        super().__init__(parent)\n        self._root = None if root is None else weakref.ref(root)\n        self._node = weakref.ref(node)\n        self._desc: desc.Attribute = attributeDesc\n        self._isOutput: bool = isOutput\n        self._enabled: bool = True\n        self._depth: int = root.depth + 1 if root is not None else 0\n        self._exposed: bool = root.exposed if root is not None else attributeDesc.exposed\n        self._invalidate = False if self._isOutput else attributeDesc.invalidate\n        self._invalidationValue = \"\"  # invalidation value for output attributes\n        self._value = None\n        self._keyValues = None  # list of pairs (key, value) for keyable attribute\n        self._linkExpression: Optional[str] = None\n        self._initValue()\n\n    def _getFullName(self) -> str:\n        \"\"\"\n        Get the attribute name following the path from the node to the attribute.\n        Return: nodeName.groupName.subGroupName.name\n        \"\"\"\n        return f'{self.node.name}.{self._getRootName()}'\n\n    def _getRootName(self) -> str:\n        \"\"\"\n        Get the attribute name following the path from the root attribute.\n        Return: groupName.subGroupName.name\n        \"\"\"\n        if isinstance(self.root, ListAttribute):\n            return f'{self.root.rootName}[{self.root.index(self)}]'\n        elif isinstance(self.root, GroupAttribute):\n            return f'{self.root.rootName}.{self._desc.name}'\n        return self._desc.name\n\n    def asLinkExpr(self) -> str:\n        \"\"\"\n        Return the link expression for this Attribute.\n        \"\"\"\n        return \"{\" + self._getFullName() + \"}\"\n\n    def requestGraphUpdate(self):\n        if self.node.graph:\n            self.node.graph.markNodesDirty(self.node)\n            self.node.graph.update()\n\n    def requestNodeUpdate(self):\n        # Update specific node information that do not affect the rest of the graph\n        # (like internal attributes)\n        if self.node:\n            self.node.updateInternalAttributes()\n\n    def executeValue(self, value):\n        \"\"\"\n        Assume value is a callable\n        Analyze value signature to detect if we want to use node or attr as parameter.\n        This method may be removed when all the legacy code is transformed.\n\n        Args:\n            value (Callable): the callable to execute\n\n        Return the result value of the callable\n        \"\"\"\n        # The new behavior is to provide the node to the callable.\n        # For compatibility with the old behavior providing the attribute, we check if the attribute is named \"attr\" and provide the attribute.\n        params = inspect.signature(value).parameters\n        if len(params) == 1 and list(params)[0] == \"attr\":\n            return value(self)\n        \n        return value(self.node)\n\n    def _initValue(self):\n        \"\"\"\n        Initialize the attribute value.\n        Called in the attribute factory for each attributes.\n        \"\"\"\n        if self._desc.keyable:\n            # Keyable attribute, initialize keyValues from attribute description\n            self._keyValues = KeyValues(self._desc)\n            # Send signal and updates if keyValues changed\n            self._keyValues.pairsChanged.connect(self._onKeyValuesChanged)\n        elif self._desc._valueType is not None:\n            self._value = self._desc._valueType()\n\n    def _getEvalValue(self):\n        \"\"\"\n        Return the value of a the attribute.\n        For string, expressions will be evaluated.\n        \"\"\"\n        if isinstance(self.value, str):\n            env = self.node.nodePlugin.configFullEnv if self.node.nodePlugin else os.environ\n            substituted = Template(self.value).safe_substitute(env)\n            try:\n                varResolved = substituted.format(**self.node._expVars, **self.node._staticExpVars)\n                return varResolved\n            except (KeyError, IndexError):\n                # Catch KeyErrors and IndexErros to be able to open files created prior to the\n                # support of relative variables (when self.node._expVars was not used to evaluate\n                # expressions in the attribute)\n                return substituted\n            except (ValueError):\n                return \"\"\n        return self.value\n\n    def _getValue(self):\n        \"\"\"\n        Return the value of the attribute or the linked attribute value.\n        \"\"\"\n        if self.keyable:\n            raise RuntimeError(f\"Cannot get value of {self._getFullName()}, the attribute is keyable.\")\n        if self.isLink:\n            return self._getInputLink().value\n        return self._value\n\n    def _setValue(self, value):\n        \"\"\"\n        Set the attribute value from a given value, a given function or a given attribute.\n        \"\"\"\n        if self._value == value:\n            return\n        if self._handleLinkValue(value):\n            if self.keyable:\n                self._keyValues.reset()\n            return\n        elif self.keyable and isinstance(value, dict):\n            # keyable attribute initialize from a dict\n            self.keyValues.resetFromDict(value)\n        elif self.keyable:\n            # keyable attribute but value is not a dict\n            raise RuntimeError(f\"Cannot set value of {self._getFullName()}, the attribute is keyable.\")\n        elif callable(value):\n            # evaluate the function\n            self._value = self.executeValue(value)\n        else:\n            # if we set a new value, we use the attribute descriptor validator to check the\n            # validity of the value and apply some conversion if needed\n            convertedValue = self.validateValue(value)\n            self._value = convertedValue\n            self.expressionApplied.emit()\n        # Request graph update when input parameter value is set\n        # and parent node belongs to a graph\n        # Output attributes value are set internally during the update process,\n        # which is why we do not trigger any update in this case\n        # TODO: update only the nodes impacted by this change\n        # TODO: only update the graph if this attribute participates to a UID\n        if self.isInput:\n            self.requestGraphUpdate()\n            # TODO: only call update of the node if the attribute is internal\n            # Internal attributes are set as inputs\n            self.requestNodeUpdate()\n        self.valueChanged.emit()\n\n    def _getKeyValues(self):\n        \"\"\"\n        Return the per-key values object of the attribute or of the linked attribute.\n        \"\"\"\n        if not self.keyable:\n            raise RuntimeError(f\"Cannot get keyValues of {self._getFullName()}, the attribute is not keyable.\")\n        if self.isLink:\n            return self._getInputLink().keyValues\n        return self._keyValues\n\n    def _handleLinkValue(self, value) -> bool:\n        \"\"\"\n        Handle the assignment of a link if `value` is a serialized link expression\n        or an in-memory Attribute reference.\n\n        Returns:\n            True if the value has been handled as a link, False otherwise.\n        \"\"\"\n        isAttribute = isinstance(value, Attribute)\n        isLinkExpression = Attribute.isLinkExpression(value)\n\n        if not isAttribute and not isLinkExpression:\n            return False\n\n        if isAttribute:\n            self._linkExpression = value.asLinkExpr()\n            # If the value is a direct reference to an attribute, it can directly\n            # be converted to an edge as the source attribute already exists in\n            # memory.\n            self._applyExpr()\n        elif isLinkExpression:\n            self._linkExpression = value\n        return True\n\n    def _applyExpr(self):\n        \"\"\"\n        For string parameters with an expression (when loaded from file),\n        this function convert the expression into a real edge in the graph\n        and clear the string value.\n        \"\"\"\n        if not self.isInput or not self._linkExpression:\n            return\n\n        if not (graph := self.node.graph):\n            return\n\n        link = self._linkExpression[1:-1]\n        linkNodeName, linkAttrName = \"\", \"\"\n\n        try:\n            linkNodeName, linkAttrName = link.split(\".\", 1)\n        except ValueError as err:\n            logging.warning('Retrieve Connected Attribute from Expression failed.')\n            logging.warning(f'Expression: \"{link}\"\\nError: \"{err}\".')\n\n        try:\n            node = graph.node(linkNodeName)\n            if node is None:\n                raise InvalidEdgeError(self.fullName, link, \"Source node does not exist.\")\n            attr = node.attribute(linkAttrName)\n            if attr is None:\n                raise InvalidEdgeError(self.fullName, link, \"Source attribute does not exist.\")\n            attr.connectTo(self)\n        except InvalidEdgeError as err:\n            logging.warning(err)\n        except Exception as err:\n            logging.warning(\"An unexpected error happened during edge creation.\")\n            logging.warning(f\"Expression '{self._linkExpression}': {err}.\")\n\n        self._linkExpression = None\n        self.resetToDefaultValue()\n\n    def resetToDefaultValue(self):\n        \"\"\"\n        Reset the attribute to its default value.\n        \"\"\"\n        if self.keyable:\n            self._value = None\n            self._keyValues.reset()\n        else:\n            self._setValue(copy.copy(self.getDefaultValue()))\n\n    def getDefaultValue(self):\n        \"\"\"\n        Get the attribute default value.\n        \"\"\"\n        if callable(self._desc.value):\n            try:\n                return self.executeValue(self._desc.value)\n            except Exception as exc:\n                if not self.node.isCompatibilityNode:\n                    logging.warning(f\"Failed to evaluate 'defaultValue' (node lambda) for attribute '{self.fullName}': {exc}\")\n                return None\n        # keyable attribute default value\n        if self.keyable:\n            return {}\n        # If the node's desc value is None and this is an input attribute with a known value type,\n        # return the type's default value instead of None\n        if self._desc.value is None and not self._isOutput and self._desc._valueType is not None:\n            return self._desc._valueType()\n        # Need to force a copy, for the case where the value is a list\n        # (avoid reference to the desc value)\n        return copy.copy(self._desc.value)\n\n    def getSerializedValue(self):\n        \"\"\"\n        Get the attribute value serialized.\n        \"\"\"\n        if self.isLink:\n            return self._getInputLink().asLinkExpr()\n        if self.keyable:\n            return self._keyValues.getSerializedValues()\n        if self.isOutput and self._desc.isExpression:\n            return self.getDefaultValue()\n        return self.value\n\n    def getPrimitiveValue(self, exportDefault=True):\n        return self._value\n\n    def getValueStr(self, withQuotes=True) -> str:\n        \"\"\"\n        Return the value formatted as a string with quotes to deal with spaces.\n        If it is a string, expressions will be evaluated.\n        If it is an empty string, it will returns 2 quotes.\n        If it is an empty list, it will returns a really empty string.\n        If it is a list with one empty string element, it will returns 2 quotes.\n        \"\"\"\n        # Keyable attribute, for now return the list of pairs as a JSON sting\n        if self.keyable:\n            return self._keyValues.getJson()\n        # ChoiceParam with multiple values should be combined\n        if isinstance(self._desc, desc.ChoiceParam) and not self._desc.exclusive:\n            # Ensure value is a list as expected\n            assert (isinstance(self.value, Sequence) and not isinstance(self.value, str))\n            v = self._desc.joinChar.join(self._getEvalValue())\n            if withQuotes and v:\n                return f'\"{v}\"'\n            return v\n        # String, File, single value Choice are based on strings and should includes quotes\n        # to deal with spaces\n        if withQuotes and isinstance(self._desc, (desc.StringParam, desc.File, desc.ChoiceParam)):\n            return f'\"{self._getEvalValue()}\"'\n        return str(self._getEvalValue())\n\n    def validateValue(self, value):\n        \"\"\"\n        Ensure value is compatible with the attribute description and convert value if needed.\n        \"\"\"\n        return self._desc.validateValue(value)\n\n    def upgradeValue(self, exportedValue):\n        \"\"\"\n        Upgrade the attribute value within a compatibility node.\n        \"\"\"\n        self._setValue(exportedValue)\n\n    def _isDefault(self):\n        if self.keyable:\n            return len(self._keyValues.pairs) == 0\n        else:\n            return self._getValue() == self.getDefaultValue()\n\n    def _isValid(self):\n        \"\"\"\n        Check attribute description validValue:\n            - If it is a function, execute it and return the result\n            - Otherwise, simply return true\n        \"\"\"\n        if callable(self._desc.validValue):\n            try:\n                return self._desc.validValue(self.node)\n            except Exception as exc:\n                if not self.node.isCompatibilityNode:\n                    logging.warning(f\"Failed to evaluate 'isValid' (node lambda) for attribute '{self.fullName}': {exc}\")\n                return True\n        return True\n\n    def _is2dDisplayable(self) -> bool:\n        \"\"\"\n        Return True if the current attribute is considered as a displayable 2D file.\n        \"\"\"\n        if not self._desc.semantic:\n            return False\n        return next((imageSemantic for imageSemantic in Attribute.VALID_IMAGE_SEMANTICS\n                     if self._desc.semantic == imageSemantic), None) is not None\n\n    def _is3dDisplayable(self) -> bool:\n        \"\"\"\n        Return True if the current attribute is considered as a displayable 3D file.\n        \"\"\"\n        if self._desc.semantic == \"3d\":\n            return True\n\n        # If the attribute is a File attribute, it is an instance of str and can be iterated over\n        hasSupportedExt = isinstance(self.value, str) and any(ext in self.value for ext in Attribute.VALID_3D_EXTENSIONS)\n        if hasSupportedExt:\n            return True\n\n        return False\n\n    def _isTextDisplayable(self) -> bool:\n        \"\"\"\n        Return True if the current attribute is considered as a displayable text file.\n        \"\"\"\n        if self._desc.semantic == \"textFile\":\n            return True\n\n        # If the attribute is a File attribute, it is an instance of str and can be iterated over\n        hasSupportedExt = isinstance(self.value, str) and any(self.value.endswith(ext) for ext in Attribute.VALID_TEXT_EXTENSIONS)\n        if hasSupportedExt:\n            return True\n\n        return False\n\n    def uid(self) -> str:\n        \"\"\"\n        Compute the UID for the attribute.\n        \"\"\"\n        if self.isOutput:\n            if self._desc.isDynamicValue:\n                # If the attribute is a dynamic output, the UID is derived from the node UID.\n                # To guarantee that each output attribute receives a unique ID, we add the attribute\n                # name to it.\n                return hashValue((self.name, self.node._uid))\n            else:\n                # Only dependent on the hash of its value without the cache folder.\n                # \"/\" at the end of the link is stripped to prevent having different UIDs depending\n                # on whether the invalidation value finishes with it or not\n                strippedInvalidationValue = self._invalidationValue.rstrip(\"/\")\n                return hashValue(strippedInvalidationValue)\n        if self.isLink:\n            linkRootAttribute = self._getInputLink(recursive=True)\n            return linkRootAttribute.uid()\n        if self.keyable:\n            return self._keyValues.uid()\n        if isinstance(self._value, (list, tuple, set,)):\n            # non-exclusive choice param\n            # hash of sorted values hashed\n            return hashValue([hashValue(v) for v in sorted(self._value)])\n        return hashValue(self._value)\n\n    def updateInternals(self):\n        \"\"\"\n        Update attribute internal properties.\n        \"\"\"\n        # Emit if the enable status has changed\n        self._setEnabled(self._getEnabled())\n\n    def _getEnabled(self) -> bool:\n        if callable(self._desc.enabled):\n            try:\n                return self._desc.enabled(self.node)\n            except Exception as exc:\n                if not self.node.isCompatibilityNode:\n                    logging.warning(f\"Failed to evaluate 'enabled' (node lambda) for attribute '{self.fullName}': {exc}\")\n                return True\n        return self._desc.enabled\n\n    def _setEnabled(self, v):\n        if self._enabled == v:\n            return\n        self._enabled = v\n        self.enabledChanged.emit()\n\n    def _isLink(self) -> bool:\n        \"\"\"\n        Whether the attribute is a link to another attribute.\n        \"\"\"\n        return bool(self.node.graph and self.isInput and self.node.graph._edges and self in self.node.graph._edges.keys())\n\n    def _getInputLink(self, recursive=False) -> Attribute:\n        \"\"\"\n        Return the direct upstream connected attribute.\n        :param recursive: recursive call, return the root attribute\n        \"\"\"\n        if not self.isLink:\n            return None\n        linkAttribute = self.node.graph.edge(self).src\n        if recursive and linkAttribute.isLink:\n            return linkAttribute._getInputLink(recursive)\n        return linkAttribute\n\n    def _getOutputLinks(self) -> list[Attribute]:\n        \"\"\"\n        Return the list of direct downstream connected attributes.\n        \"\"\"\n        # Safety check to avoid evaluation errors\n        if not self.node.graph or not self.node.graph.edges:\n            return []\n        return [edge.dst for edge in self.node.graph.edges.values() if edge.src == self]\n\n    def _getAllInputLinks(self) -> list[Attribute]:\n        \"\"\"\n        Return the list of upstream connected attributes for the attribute or any of its elements.\n        \"\"\"\n        inputLink = self._getInputLink()\n        if inputLink is None:\n            return []\n        return [inputLink]\n\n    def _getAllOutputLinks(self) -> list[Attribute]:\n        \"\"\"\n        Return the list of downstream connected attributes for the attribute or any of its elements.\n        \"\"\"\n        return self._getOutputLinks()\n\n    def _hasAnyInputLinks(self) -> bool:\n        \"\"\"\n        Whether the attribute or any of its elements is a link to another attribute.\n        \"\"\"\n        # Safety check to avoid evaluation errors\n        if not self.node.graph or not self.node.graph.edges:\n            return False\n        return next((edge for edge in self.node.graph.edges.values() if edge.dst == self), None) is not None\n\n    def _hasAnyOutputLinks(self) -> bool:\n        \"\"\"\n        Whether the attribute or any of its elements is linked by another attribute.\n        \"\"\"\n        # Safety check to avoid evaluation errors\n        if not self.node.graph or not self.node.graph.edges:\n            return False\n        return next((edge for edge in self.node.graph.edges.values() if edge.src == self), None) is not None\n\n    def _getFlatStaticChildren(self) -> list[Attribute]:\n        \"\"\"\n        Return a list of all the attributes that refer to this Attribute as their parent through the\n        \"root\" property. If no such attribute exist, return an empty list.\n        The depth difference is not taken into account in the list, which is thus always flat.\n        \"\"\"\n        return []\n\n    def _validateIncomingConnection(self, connectingAttribute: Attribute) -> bool:\n        \"\"\"\n        Validation of the connection of \"connectingAttribute\" on this Attribute.\n        This method can be overridden.\n\n        Args:\n            connectingAttribute: the Attribute attempting to connect to this one.\n\n        Returns:\n            True if the connection is valid, False otherwise.\n        \"\"\"\n        return self.baseType == connectingAttribute.baseType\n\n    def connectTo(self, dstAttribute: Attribute) -> tuple[list[list[Attribute]], list[list[Attribute]]]:\n        \"\"\"\n        Connect this Attribute to \"dstAttribute\".\n\n        Args:\n            dstAttribute: the destination Attribute\n\n        Returns:\n            A tuple containing:\n                - a list containing pairs of the source and destination Attributes (as lists) for every created edge\n                - a list containing pairs of the source and destination Attributes (as lists) for every deleted edge\n        \"\"\"\n        if not (graph := self.node.graph):\n            return [], []\n\n        deletedEdges = []\n        if isinstance(dstAttribute.root, Attribute):\n            deletedEdges = dstAttribute.root.disconnectEdge()\n\n        connectedEdge, deletedEdge = graph.addEdge(self, dstAttribute)\n        if deletedEdge:\n            deletedEdges.append(deletedEdge)\n\n        return [connectedEdge], deletedEdges\n\n    def disconnectEdge(self):\n        \"\"\"\n        Disconnect and remove the edge connected to this Attribute.\n\n        Returns:\n            A list of all the Edge objects that were deleted during the disconnection.\n        \"\"\"\n        if not (graph := self.node.graph):\n            return []\n\n        deletedEdges = []\n        edge = graph.removeEdge(self)\n        if edge:\n            deletedEdges.append(edge)\n\n        if isinstance(self.root, Attribute):\n            deletedEdges += self.root.disconnectEdge()\n\n        return deletedEdges\n\n    # Slots\n\n    @Slot()\n    def _onKeyValuesChanged(self):\n        \"\"\"\n        For keyable attribute, when the list or pairs (key, value) is modified this method should be called.\n        Emit Attribute.valueChanged and update node / graph like _setValue().\n        \"\"\"\n        if self.isInput:\n            self.requestGraphUpdate()\n            self.requestNodeUpdate()\n        self.valueChanged.emit()\n\n    @Slot()\n    def _onValueChanged(self):\n        self.node._onAttributeChanged(self)\n\n    @Slot(str, result=bool)\n    def matchText(self, text: str) -> bool:\n        return self.label.lower().find(text.lower()) > -1\n\n    @Slot(BaseObject, result=bool)\n    def validateIncomingConnection(self, connectingAttribute: Attribute) -> bool:\n        \"\"\"\n        Return True if this Attribute can receive a connection from\n        \"connectingAttribute\", False otherwise.\n        \"\"\"\n        return self._validateIncomingConnection(connectingAttribute)\n\n    # Properties and signals\n\n    # The node that contains this attribute.\n    node = Property(BaseObject, lambda self: self._node(), constant=True)\n    # The attribute that contains this attribute.\n    root = Property(BaseObject, lambda self: self._root() if self._root else None, constant=True)\n    # The attribute name following the path from the node to the attribute.\n    fullName = Property(str, _getFullName, constant=True)\n    # The attribute name following the path from the root attribute.\n    rootName = Property(str, _getRootName, constant=True)\n    # The description object of the attribute.\n    desc = Property(desc.Attribute, lambda self: self._desc, constant=True)\n    # The name of the attribute.\n    name = Property(str, lambda self: self._desc._name, constant=True)\n    # The human-readable label for the attribute.\n    label = Property(str, lambda self: self._desc.label, constant=True)\n    # The type of attribute as a string.\n    type = Property(str, lambda self: self._desc.type, constant=True)\n    # The type of the elements of the attribute as a string.\n    baseType = Property(str, lambda self: self._desc.type, constant=True)\n    # Whether the attribute is a node input attribute.\n    isInput = Property(bool, lambda self: not self._isOutput, constant=True)\n    # Whether the attribute is a node output attribute.\n    isOutput = Property(bool, lambda self: self._isOutput, constant=True)\n    # Whether the attribute is a read-only attribute.\n    isReadOnly = Property(bool, lambda self: not self._isOutput and self.node.isCompatibilityNode, constant=True)\n    # Whether changing this attribute invalidates cached results.\n    invalidate = Property(bool, lambda self: self._invalidate, constant=True)\n    # Whether this attribute is enabled.\n    enabledChanged = Signal()\n    enabled = Property(bool, _getEnabled, _setEnabled, notify=enabledChanged)\n    # Depth level of this attribute.\n    depth = Property(int, lambda self: self._depth, constant=True)\n    # Whether the attribute is exposed (if it has a parent, the parent's value\n    # takes precedence over the description's).\n    exposed = Property(bool, lambda self: self._exposed, constant=True)\n\n    # Attribute value properties and signals\n    valueChanged = Signal()\n    value = Property(Variant, _getValue, _setValue, notify=valueChanged)\n    evalValue = Property(Variant, _getEvalValue, notify=valueChanged)\n    # Whether the attribute can have a distinct value per key.\n    keyable = Property(bool, lambda self: self._desc.keyable, constant=True)\n    # The list of pairs (key, value) of the attribute.\n    keyValues = Property(Variant, _getKeyValues, notify=valueChanged)\n\n    # Whether the attribute value is the default value.\n    isDefault = Property(bool, _isDefault, notify=valueChanged)\n    # Whether the attribute value is valid.\n    isValid = Property(bool, _isValid, notify=valueChanged)\n    # Whether the attribute value is displayable in 2d.\n    is2dDisplayable = Property(bool, _is2dDisplayable, constant=True)\n    # Whether the attribute value is displayable in 3d.\n    is3dDisplayable = Property(bool, _is3dDisplayable, constant=True)\n    # Whether the attribute value is displayable as text.\n    isTextDisplayable = Property(bool, _isTextDisplayable, constant=True)\n    # Whether the attribute is a shape or a shape list, managed by the ShapeEditor and ShapeViewer.\n    hasDisplayableShape = Property(bool, lambda self: False, constant=True)\n\n    # Attribute link properties and signals\n    inputLinksChanged = Signal()\n    outputLinksChanged = Signal()\n\n    # Whether the attribute is a link to another attribute.\n    isLink = Property(bool, _isLink, notify=inputLinksChanged)\n    # The upstream connected root attribute.\n    inputRootLink = Property(Variant, lambda self: self._getInputLink(recursive=True), notify=inputLinksChanged)\n    # The upstream connected attribute.\n    inputLink = Property(BaseObject, _getInputLink, notify=inputLinksChanged)\n    # The list of downstream connected attributes.\n    outputLinks = Property(Variant, _getOutputLinks, notify=outputLinksChanged)\n    # The list of upstream connected attributes for the attribute or any of its elements.\n    allInputLinks = Property(Variant, _getAllInputLinks, notify=inputLinksChanged)\n    # The list of downstream connected attributes for the attribute or any of its elements.\n    allOutputLinks = Property(Variant, _getAllOutputLinks, notify=outputLinksChanged)\n    # Whether the attribute or any of its elements is a link to another attribute.\n    hasAnyInputLinks = Property(bool, _hasAnyInputLinks, notify=inputLinksChanged)\n    # Whether the attribute or any of its elements is linked by another attribute.\n    hasAnyOutputLinks = Property(bool, _hasAnyOutputLinks, notify=outputLinksChanged)\n    # The list of attributes that refer to this one as their parent.\n    flatStaticChildren = Property(Variant, _getFlatStaticChildren, constant=True)\n\n    expressionApplied = Signal()\n\n\ndef raiseIfLink(func):\n    \"\"\"\n    If Attribute instance is a link, raise a RuntimeError.\n    \"\"\"\n    def wrapper(attr, *args, **kwargs):\n        if attr.isLink:\n            raise RuntimeError(\"Can't modify connected Attribute\")\n        return func(attr, *args, **kwargs)\n    return wrapper\n\n\nclass PushButtonParam(Attribute):\n    def __init__(self, node, attributeDesc: desc.PushButtonParam, isOutput: bool,\n                 root=None, parent=None):\n        super().__init__(node, attributeDesc, isOutput, root, parent)\n\n    @Slot()\n    def clicked(self):\n        self.node.onAttributeClicked(self)\n\n\nclass ChoiceParam(Attribute):\n\n    def __init__(self, node, attributeDesc: desc.ChoiceParam, isOutput: bool,\n                 root=None, parent=None):\n        super().__init__(node, attributeDesc, isOutput, root, parent)\n        self._values = None\n\n    def __len__(self):\n        return len(self.getValues())\n\n    def getValues(self):\n        if (linkParam := self._getInputLink()) is not None:\n            return linkParam.getValues()\n        return self._values if self._values is not None else self._desc._values\n\n    def setValues(self, values):\n        if values == self._values:\n            return\n        self._values = values\n        self.valuesChanged.emit()\n\n    # Override\n    def validateValue(self, value):\n        if self._desc.exclusive:\n            return self._conformValue(value)\n        if isinstance(value, str):\n            value = value.split(',')\n        if not isinstance(value, Iterable):\n            raise ValueError(f\"Non exclusive ChoiceParam value should be iterable (param: {self.name}, \"\n                             f\"value: {value}, type: {type(value)})\")\n        return [self._conformValue(v) for v in value]\n\n    def _conformValue(self, val):\n        \"\"\"\n        Conform 'val' to the correct type and check for its validity\n        \"\"\"\n        return self._desc.conformValue(val)\n\n    # Override\n    def _setValue(self, value):\n        # Handle alternative serialization for ChoiceParam with overriden values.\n        serializedValueWithValuesOverrides = isinstance(value, dict)\n        if serializedValueWithValuesOverrides:\n            super()._setValue(value[self._desc._OVERRIDE_SERIALIZATION_KEY_VALUE])\n            self.setValues(value[self._desc._OVERRIDE_SERIALIZATION_KEY_VALUES])\n        else:\n            super()._setValue(value)\n\n    # Override\n    def getSerializedValue(self):\n        useStandardSerialization = self.isLink or not self._desc._saveValuesOverride or \\\n            self._values is None\n        if useStandardSerialization:\n            return super().getSerializedValue()\n        return {\n            self._desc._OVERRIDE_SERIALIZATION_KEY_VALUE: self._value,\n            self._desc._OVERRIDE_SERIALIZATION_KEY_VALUES: self._values,\n        }\n\n    value = Property(Variant, Attribute._getValue, _setValue, notify=Attribute.valueChanged)\n    valuesChanged = Signal()\n    values = Property(Variant, getValues, setValues, notify=valuesChanged)\n\n\nclass ListAttribute(Attribute):\n\n    def __init__(self, node, attributeDesc: desc.ListAttribute, isOutput: bool,\n                 root=None, parent=None):\n        super().__init__(node, attributeDesc, isOutput, root, parent)\n\n    def __len__(self):\n        if self.value is None:\n            return 0\n        return len(self.value)\n\n    def __iter__(self):\n        return iter(self.value)\n\n    def at(self, idx):\n        \"\"\"\n        Returns child attribute at index 'idx'.\n        \"\"\"\n        # Implement 'at' rather than '__getitem__'\n        # since the later is called spuriously when object is used in QML\n        return self.value.at(idx)\n\n    def index(self, item):\n        return self.value.indexOf(item)\n\n    @raiseIfLink\n    def append(self, value):\n        self.extend([value])\n\n    @raiseIfLink\n    def extend(self, values):\n        self.insert(len(self), values)\n\n    @raiseIfLink\n    def insert(self, index, value):\n        if self._value is None:\n            self._value = ListModel(parent=self)\n        values = value if isinstance(value, list) else [value]\n        attrs = [attributeFactory(self._desc.elementDesc, v, self.isOutput, self.node, self)\n                 for v in values]\n        self._value.insert(index, attrs)\n        self.valueChanged.emit()\n        self._applyExpr()\n        self.requestGraphUpdate()\n\n    @raiseIfLink\n    def remove(self, index, count=1):\n        if self._value is None:\n            return\n        if self.node.graph:\n            from meshroom.core.graph import GraphModification\n            with GraphModification(self.node.graph):\n                # remove potential links\n                for i in range(index, index + count):\n                    attr = self._value.at(i)\n                    if attr.isLink:\n                        # delete edge if the attribute is linked\n                        self.node.graph.removeEdge(attr)\n        self._value.removeAt(index, count)\n        self.requestGraphUpdate()\n        self.valueChanged.emit()\n\n    # Override\n    def _initValue(self):\n        self.resetToDefaultValue()\n\n    # Override\n    def _setValue(self, value):\n        if self.node.graph:\n            self.remove(0, len(self))\n        if self._handleLinkValue(value):\n            return\n        # New value\n        else:\n            # During initialization self._value may not be set\n            if self._value is None:\n                self._value = ListModel(parent=self)\n            newValue = self._desc.validateValue(value)\n            self.extend(newValue)\n        self.requestGraphUpdate()\n\n    # Override\n    def _applyExpr(self):\n        if self._linkExpression:\n            super()._applyExpr()\n        else:\n            for value in self._value:\n                value._applyExpr()\n\n    # Override\n    def resetToDefaultValue(self):\n        self._value = ListModel(parent=self)\n        self.valueChanged.emit()\n\n    # Override\n    def getDefaultValue(self) -> list:\n        return []\n\n    # Override\n    def getSerializedValue(self):\n        if self.isLink:\n            return self._getInputLink().asLinkExpr()\n        return [attr.getSerializedValue() for attr in self._value]\n\n    # Override\n    def getPrimitiveValue(self, exportDefault=True):\n        if exportDefault:\n            return [attr.getPrimitiveValue(exportDefault=exportDefault) for attr in self._value]\n        return [attr.getPrimitiveValue(exportDefault=exportDefault) for attr in self._value\n                if not attr.isDefault]\n\n    # Override\n    def getValueStr(self, withQuotes=True) -> str:\n        assert isinstance(self.value, ListModel)\n        if self._desc.joinChar == ' ':\n            return self._desc.joinChar.join([v.getValueStr(withQuotes=withQuotes)\n                                                     for v in self.value])\n        v = self._desc.joinChar.join([v.getValueStr(withQuotes=False)\n                                              for v in self.value])\n        if withQuotes and v:\n            return f'\"{v}\"'\n        return v\n\n    # Override\n    def upgradeValue(self, exportedValues):\n        if self._handleLinkValue(exportedValues):\n            return\n        if not isinstance(exportedValues, list):\n            raise RuntimeError(\"ListAttribute.upgradeValue: the given value is of type \" +\n                               str(type(exportedValues)) + \" but a 'list' is expected.\")\n        attrs = []\n        for v in exportedValues:\n            a = attributeFactory(self._desc.elementDesc, None, self.isOutput,\n                                 self.node, self)\n            a.upgradeValue(v)\n            attrs.append(a)\n        index = len(self._value)\n        self._value.insert(index, attrs)\n        self.valueChanged.emit()\n        self._applyExpr()\n        self.requestGraphUpdate()\n\n    # Override\n    def uid(self):\n        if isinstance(self.value, ListModel):\n            uids = []\n            for value in self.value:\n                if value.invalidate:\n                    uids.append(value.uid())\n            return hashValue(uids)\n        return super().uid()\n\n    # Override\n    def updateInternals(self):\n        super().updateInternals()\n        for attr in self._value:\n            attr.updateInternals()\n\n    # Override\n    def _getAllInputLinks(self) -> list[Attribute]:\n        \"\"\"\n        Return the list of upstream connected attributes for the attribute or any of its elements.\n        \"\"\"\n        # Safety check to avoid evaluation errors\n        if not self.node.graph or not self.node.graph.edges:\n            return []\n        return [edge.src for edge in self.node.graph.edges.values() if edge.dst == self or edge.dst in self._value]\n\n    # Override\n    def _getAllOutputLinks(self) -> list[Attribute]:\n        \"\"\"\n        Return the list of downstream connected attributes for the attribute or any of its elements.\n        \"\"\"\n        # Safety check to avoid evaluation errors\n        if not self.node.graph or not self.node.graph.edges:\n            return []\n        return [edge.dst for edge in self.node.graph.edges.values() if edge.src == self or edge.src in self._value]\n\n    # Override\n    def _hasAnyInputLinks(self) -> bool:\n        \"\"\"\n        Whether the attribute or any of its elements is a link to another attribute.\n        \"\"\"\n        return super()._hasAnyInputLinks() or \\\n               any(attribute.hasAnyInputLinks for attribute in self._value if hasattr(attribute, 'hasAnyInputLinks'))\n\n    # Override\n    def _hasAnyOutputLinks(self) -> bool:\n        \"\"\"\n        Whether the attribute or any of its elements is linked by another attribute.\n        \"\"\"\n        return super()._hasAnyOutputLinks() or \\\n               any(attribute.hasAnyOutputLinks for attribute in self._value if hasattr(attribute, 'hasAnyOutputLinks'))\n\n    # Override value property setter\n    value = Property(Variant, Attribute._getValue, _setValue, notify=Attribute.valueChanged)\n    isDefault = Property(bool, lambda self: len(self.value) == 0, notify=Attribute.valueChanged)\n    baseType = Property(str, lambda self: self._desc.elementDesc.__class__.__name__, constant=True)\n\n    # Override attribute link properties\n    allInputLinks = Property(Variant, _getAllInputLinks, notify=Attribute.inputLinksChanged)\n    allOutputLinks = Property(Variant, _getAllOutputLinks, notify=Attribute.outputLinksChanged)\n    hasAnyInputLinks = Property(bool, _hasAnyInputLinks, notify=Attribute.inputLinksChanged)\n    hasAnyOutputLinks = Property(bool, _hasAnyOutputLinks, notify=Attribute.outputLinksChanged)\n\n\nclass GroupAttribute(Attribute):\n\n    def __init__(self, node, attributeDesc: desc.GroupAttribute, isOutput: bool,\n                 root=None, parent=None):\n        super().__init__(node, attributeDesc, isOutput, root, parent)\n\n    def __getattr__(self, key):\n        try:\n            return super().__getattr__(key)\n        except AttributeError:\n            try:\n                return self._value.get(key)\n            except KeyError:\n                raise AttributeError(key)\n\n    # Override\n    def _initValue(self):\n        self._value = DictModel(keyAttrName='name', parent=self)\n        subAttributes = []\n        for subAttrDesc in self._desc.items:\n            childAttr = attributeFactory(subAttrDesc, None, self.isOutput, self.node, self)\n            subAttributes.append(childAttr)\n            childAttr.valueChanged.connect(self.valueChanged)\n        self._value.reset(subAttributes)\n\n    # Override\n    def _getValue(self):\n        return self._value\n\n    # Override\n    def _setValue(self, exportedValue):\n        if self._handleLinkValue(exportedValue):\n            return\n\n        value = self.validateValue(exportedValue)\n        if isinstance(value, dict):\n            # set individual child attribute values\n            for key, v in value.items():\n                self._value.get(key).value = v\n        elif isinstance(value, (list, tuple)):\n            if len(self._desc._items) != len(value):\n                raise AttributeError(f\"Incorrect number of values on GroupAttribute: {str(value)}\")\n            for attrDesc, v in zip(self._desc._items, value):\n                self._value.get(attrDesc.name).value = v\n        else:\n            raise AttributeError(f\"Failed to set on GroupAttribute: {str(value)}\")\n\n    # Override\n    def _applyExpr(self):\n        if self._linkExpression:\n            super()._applyExpr()\n        else:\n            for value in self._value:\n                value._applyExpr()\n\n    # Override\n    def resetToDefaultValue(self):\n        for attrDesc in self._desc._items:\n            self._value.get(attrDesc.name).resetToDefaultValue()\n\n    # Override\n    def getDefaultValue(self):\n        return {key: attr.getDefaultValue() for key, attr in self._value.items()}\n\n    # Override\n    def getSerializedValue(self):\n        if self.inputLink:\n            return self.inputLink.asLinkExpr()\n        return {key: attr.getSerializedValue() for key, attr in self._value.objects.items()}\n\n    # Override\n    def getPrimitiveValue(self, exportDefault=True):\n        if exportDefault:\n            return {name: attr.getPrimitiveValue(exportDefault=exportDefault) for name, attr in self._value.items()}\n        return {name: attr.getPrimitiveValue(exportDefault=exportDefault) for name, attr in self._value.items()\n                if not attr.isDefault}\n\n    # Override\n    def getValueStr(self, withQuotes=True):\n        # add brackets if requested\n        strBegin = ''\n        strEnd = ''\n        if self._desc.brackets is not None:\n            if len(self._desc.brackets) == 2:\n                strBegin = self._desc.brackets[0]\n                strEnd = self._desc.brackets[1]\n            else:\n                raise AttributeError(f\"Incorrect brackets on GroupAttribute: {self._desc.brackets}\")\n        # particular case when using space separator\n        spaceSep = self._desc.joinChar == ' '\n        # sort values based on child attributes group description order\n        sortedSubValues = [self._value.get(attr.name).getValueStr(withQuotes=spaceSep)\n                           for attr in self._desc.items]\n        s = self._desc.joinChar.join(sortedSubValues)\n        if withQuotes and not spaceSep:\n            return f'\"{strBegin}{s}{strEnd}\"'\n        return f'{strBegin}{s}{strEnd}'\n\n    # Override\n    def upgradeValue(self, exportedValue):\n        if self._handleLinkValue(exportedValue):\n            return\n\n        value = self.validateValue(exportedValue)\n        if isinstance(value, dict):\n            # set individual child attribute values\n            for key, v in value.items():\n                if key in self._value.keys():\n                    self._value.get(key).upgradeValue(v)\n        elif isinstance(value, (list, tuple)):\n            if len(self._desc._items) != len(value):\n                raise AttributeError(f\"Incorrect number of values on GroupAttribute: {str(value)}\")\n            for attrDesc, v in zip(self._desc._items, value):\n                self._value.get(attrDesc.name).upgradeValue(v)\n        else:\n            raise AttributeError(f\"Failed to set on GroupAttribute: {str(value)}\")\n\n    # Override\n    def uid(self):\n        if self.isLink:\n            return super().uid()\n\n        uids = []\n        for _, v in self._value.items():\n            if v.enabled and v.invalidate:\n                uids.append(v.uid())\n        return hashValue(uids)\n\n    # Override\n    def updateInternals(self):\n        super().updateInternals()\n        for attr in self._value:\n            attr.updateInternals()\n\n    # Override\n    def _getFlatStaticChildren(self) -> list[Attribute]:\n        attributes = []\n\n        # Iterate over the values and add the flat children of every child (if they exist)\n        for attribute in self.value:\n            attributes.append(attribute)\n            attributes += attribute.flatStaticChildren\n\n        return attributes\n\n    # Override\n    def _validateIncomingConnection(self, connectingAttribute: Attribute) -> bool:\n        valid = super()._validateIncomingConnection(connectingAttribute)\n\n        if not valid:  # Attributes are not of the same base type\n            return False\n\n        return self._hasMatchingStructure(connectingAttribute)\n\n    def _hasMatchingStructure(self, otherAttribute: Attribute) -> bool:\n        \"\"\"\n        Check whether this GroupAttribute and another Attribute have matching structures.\n\n        Attributes have matching structures if they have the same number of children and if, at each position,\n        both Attributes have the same base type.\n\n        Args:\n            otherAttribute: the other Attribute to compare structure with\n\n        Returns:\n            True if both Attributes have the same structure, False otherwise\n        \"\"\"\n        flatAttrs = self.flatStaticChildren\n        otherFlatAttrs = otherAttribute.flatStaticChildren\n\n        if len(flatAttrs) != len(otherFlatAttrs):\n            return False\n\n        for index, attribute in enumerate(flatAttrs):\n            if attribute.baseType != otherFlatAttrs[index].baseType:\n                return False\n\n        return True\n\n    # Override\n    def connectTo(self, dstAttribute: GroupAttribute) -> tuple[list[list[Attribute]], list[list[Attribute]]]:\n        \"\"\"\n        Connect this GroupAttribute to \"dstAttribute\". The nested attributes in the group\n        are automatically connected.\n\n        Args:\n            dstAttribute: the destination Attribute\n\n        Returns:\n            A tuple containing:\n                - a list containing pairs of the source and destination Attributes (as lists) for every created edge\n                - a list containing pairs of the source and destination Attributes (as lists) for every deleted edge\n        \"\"\"\n        nestedDstAttributes = list(dstAttribute.value)\n        connectedEdges = []\n        deletedEdges = []\n\n        for index, nestedAttribute in enumerate(list(self.value)):\n            # If the attributes are already connected, do not connect them again\n            if not nestedDstAttributes[index] in nestedAttribute.outputLinks:\n                connected, deleted = nestedAttribute.connectTo(nestedDstAttributes[index])\n                connectedEdges += connected\n                deletedEdges += deleted\n        connected, deleted = super().connectTo(dstAttribute)\n        connectedEdges += connected\n        deletedEdges += deleted\n\n        return connectedEdges, deletedEdges\n\n    @Slot(str, result=Attribute)\n    def childAttribute(self, key: str) -> Attribute:\n        \"\"\"\n        Get child attribute by name or None if none was found.\n\n        Args:\n            key: the name of the child attribute\n\n        Returns:\n            Attribute: the child attribute or None\n        \"\"\"\n        try:\n            return self._value.get(key)\n        except KeyError:\n            return None\n\n    # Override\n    @Slot(str, result=bool)\n    def matchText(self, text: str) -> bool:\n        return super().matchText(text) or any(c.matchText(text) for c in self._value)\n\n    # Override value property\n    value = Property(Variant, _getValue, _setValue, notify=Attribute.valueChanged)\n    # Override flatStaticChildren property\n    flatStaticChildren = Property(Variant, _getFlatStaticChildren, constant=True)\n    isDefault = Property(bool, lambda self: all(v.isDefault for v in self.value),\n                         notify=Attribute.valueChanged)\n\n\nclass GeometryAttribute(GroupAttribute):\n    \"\"\"\n    GroupAttribute subtype tailored for geometry-specific handling.\n    \"\"\"\n\n    def __init__(self, node, attributeDesc: desc.Geometry, isOutput: bool, root=None, parent=None):\n        super().__init__(node, attributeDesc, isOutput, root, parent)\n\n    # Override\n    # Signal observationsChanged should be emitted.\n    def _setValue(self, exportedValue):\n        super()._setValue(exportedValue)\n        self.observationsChanged.emit()\n\n    # Override\n    # Signal observationsChanged should be emitted.\n    def resetToDefaultValue(self):\n        super().resetToDefaultValue()\n        self.observationsChanged.emit()\n\n    # Override\n    # Signal observationsChanged should be emitted.\n    def upgradeValue(self, exportedValue):\n        super().upgradeValue(exportedValue)\n        self.observationsChanged.emit()\n\n    # Override\n    # Fix missing link expression serialization.\n    # Should be remove if link expression serialization is added in GroupAttribute.\n    def getSerializedValue(self):\n        if self.isLink:\n            return self._getInputLink().asLinkExpr()\n        return super().getSerializedValue()\n\n    def getValueAsDict(self) -> dict:\n        \"\"\"\n        Return the geometry attribute value as dict.\n        For not keyable geometry, this is the same as getSerializedValue().\n        For keyable geometry, the dict is indexed by key.\n        \"\"\"\n        from collections import defaultdict\n        outValue = defaultdict(dict)\n        if not self.observationKeyable:\n            return super().getSerializedValue()\n        for attribute in self.value:\n            if isinstance(attribute, GeometryAttribute):\n                attributeDict = attribute.getValueAsDict()\n                if attributeDict:\n                    for key, value in attributeDict.items():\n                        outValue[key][attribute.name] = value\n            else:\n                for pair in attribute.keyValues.pairs:\n                    outValue[str(pair.key)][attribute.name] = pair.value\n        return dict(outValue)\n\n    def _hasKeyableChilds(self) -> bool:\n        \"\"\"\n        Whether all child attributes are keyable.\n        \"\"\"\n        return all((isinstance(attribute, GeometryAttribute) and attribute.observationKeyable) or\n                    attribute.keyable for attribute in self.value)\n\n    def _getNbObservations(self) -> int:\n        \"\"\"\n        Return the geometry attribute number of observations.\n        Note: Observation is a value defined across all child attributes for a specific key.\n        \"\"\"\n        if self.observationKeyable:\n            firstAttribute = next(iter(self.value.values()))\n            if isinstance(firstAttribute, GeometryAttribute):\n                return firstAttribute.nbObservations\n            return len(firstAttribute.keyValues.pairs)\n        return 1\n\n    def _getObservationKeys(self) -> list:\n        \"\"\"\n        Return the geometry attribute list of observation keys.\n        Note: Observation is a value defined across all child attributes for a specific key.\n        \"\"\"\n        if not self.observationKeyable:\n            return []\n        firstAttribute = next(iter(self.value.values()))\n        if isinstance(firstAttribute, GeometryAttribute):\n            return firstAttribute.observationKeys\n        return firstAttribute.keyValues.getKeys()\n\n    @Slot(str, result=bool)\n    def hasObservation(self, key: str) -> bool:\n        \"\"\"\n        Whether the geometry attribute has an observation for the given key.\n        Note: Observation is a value defined across all child attributes for a specific key.\n        \"\"\"\n        if not self.observationKeyable:\n            return True\n        return all((isinstance(attribute, GeometryAttribute) and attribute.hasObservation(key)) or\n                   (not isinstance(attribute, GeometryAttribute) and attribute.keyValues.hasKey(key))\n                   for attribute in self.value)\n\n    @raiseIfLink\n    def removeObservation(self, key: str):\n        \"\"\"\n        Remove the geometry attribute observation for the given key.\n        Note: Observation is a value defined across all child attributes for a specific key.\n        \"\"\"\n        for attribute in self.value:\n            if isinstance(attribute, GeometryAttribute):\n                attribute.removeObservation(key)\n            else:\n                if attribute.keyable:\n                    attribute.keyValues.remove(key)\n                else:\n                    attribute.resetToDefaultValue()\n        self.observationsChanged.emit()\n\n    @raiseIfLink\n    def setObservation(self, key: str, observation: Variant):\n        \"\"\"\n        Set the geometry attribute observation for the given key with the given observation.\n        Note: Observation is a value defined across all child attributes for a specific key.\n        \"\"\"\n        for attributeStr, value in observation.items():\n            attribute = self.childAttribute(attributeStr)\n            if attribute is None:\n                raise RuntimeError(f\"Cannot set geometry observation for attribute {self._getFullName()} \\\n                                   observation is incorrect.\")\n            if isinstance(attribute, GeometryAttribute):\n                attribute.setObservation(key, value)\n            else:\n                if attribute.keyable:\n                    attribute.keyValues.add(key, value)\n                else:\n                    attribute.value = value\n        self.observationsChanged.emit()\n\n    @Slot(str, result=Variant)\n    def getObservation(self, key: str) -> Variant:\n        \"\"\"\n        Return the geometry attribute observation for the given key.\n        Note: Observation is a value defined across all child attributes for a specific key.\n        \"\"\"\n        observation = {}\n        for attribute in self.value:\n            if isinstance(attribute, GeometryAttribute):\n                geoObservation = attribute.getObservation(key)\n                if geoObservation is None:\n                    return None\n                else:\n                    observation[attribute.name] = geoObservation\n            else:\n                if attribute.keyable:\n                    if attribute.keyValues.hasKey(key):\n                        observation[attribute.name] = attribute.keyValues.getValueAtKeyOrDefault(key)\n                    else:\n                        return None\n                else:\n                    observation[attribute.name] = attribute.value\n        return observation\n\n    # Properties and signals\n    # Emitted when a geometry observation changed.\n    observationsChanged = Signal()\n    # Whether the geometry attribute childs are keyable.\n    observationKeyable = Property(bool, _hasKeyableChilds, constant=True)\n    # The list of geometry observation keys.\n    observationKeys = Property(Variant, _getObservationKeys, notify=observationsChanged)\n    # The number of geometry observation defined.\n    nbObservations = Property(int, _getNbObservations, notify=observationsChanged)\n\n\nclass ShapeAttribute(GroupAttribute):\n    \"\"\"\n    GroupAttribute subtype tailored for shape-specific handling.\n    \"\"\"\n\n    def __init__(self, node, attributeDesc: desc.Shape, isOutput: bool, root=None, parent=None):\n        super().__init__(node, attributeDesc, isOutput, root, parent)\n        self._visible = True\n\n    # Override\n    # Connect geometry attribute valueChanged to emit geometryChanged signal.\n    def _initValue(self):\n        super()._initValue()\n        # Using Attribute.valueChanged for the userName, userColor, geometry properties results\n        # in a segmentation fault.\n        # As a workaround, we manually connect valueChanged to shapeChanged or geometryChanged.\n        self.value.get(\"userName\").valueChanged.connect(self._onShapeChanged)\n        self.value.get(\"userColor\").valueChanged.connect(self._onShapeChanged)\n        self.geometry.valueChanged.connect(self._onGeometryChanged)\n\n    # Override\n    # Fix missing link expression serialization.\n    # Should be remove if link expression serialization is added in GroupAttribute.\n    def getSerializedValue(self):\n        if self.isLink:\n            return self._getInputLink().asLinkExpr()\n        return super().getSerializedValue()\n\n    def getShapeAsDict(self) -> dict:\n        \"\"\"\n        Return the shape attribute as dict with the shape file structure.\n        \"\"\"\n        outDict = {\n            \"name\": self.userName if self.userName else self.rootName,\n            \"type\": self.type,\n            \"properties\": {\"color\": self.userColor}\n        }\n        if not self.geometry.observationKeyable:\n            # Not keyable geometry, use properties.\n            outDict.get(\"properties\").update(self.geometry.getSerializedValue())\n        else:\n            # Keyable geometry, use observations.\n            outDict.update({\"observations\": self.geometry.getValueAsDict()})\n        return outDict\n\n    def _getVisible(self) -> bool:\n        \"\"\"\n        Return whether the shape attribute is visible for display.\n        \"\"\"\n        return self._visible\n\n    def _setVisible(self, visible: bool):\n        \"\"\"\n        Set the shape attribute visibility for display.\n        \"\"\"\n        self._visible = visible\n        self.shapeChanged.emit()\n\n    def _getUserName(self) -> str:\n        \"\"\"\n        Return the shape attribute user name for display.\n        \"\"\"\n        return self.value.get(\"userName\").value\n\n    def _getUserColor(self) -> str:\n        \"\"\"\n        Return the shape attribute user color for display.\n        \"\"\"\n        return self.value.get(\"userColor\").value\n\n    @Slot()\n    def _onShapeChanged(self):\n        \"\"\"\n        Emit shapeChanged signal.\n        Used when shape userName or userColor value changed.\n        \"\"\"\n        self.shapeChanged.emit()\n\n    @Slot()\n    def _onGeometryChanged(self):\n        \"\"\"\n        Emit geometryChanged signal.\n        Used when geometry attribute value changed.\n        \"\"\"\n        self.geometryChanged.emit()\n\n    # Properties and signals\n    # Emitted when a shape related property changed (color, visibility).\n    shapeChanged = Signal()\n    # Emitted when a shape observation changed.\n    geometryChanged = Signal()\n    # Whether the shape is displayable.\n    isVisible = Property(bool, _getVisible, _setVisible, notify=shapeChanged)\n    # The shape user name for display.\n    userName = Property(str, _getUserName, notify=shapeChanged)\n    # The shape user color for display.\n    userColor = Property(str, _getUserColor, notify=shapeChanged)\n    # The shape geometry group attribute.\n    geometry = Property(Variant, lambda self: self.value.get(\"geometry\"), notify=geometryChanged)\n    # Override hasDisplayableShape property.\n    hasDisplayableShape = Property(bool, lambda self: True, constant=True)\n\n\nclass ShapeListAttribute(ListAttribute):\n    \"\"\"\n    ListAttribute subtype tailored for shape-specific handling.\n    \"\"\"\n\n    def __init__(self, node, attributeDesc: desc.ShapeList, isOutput: bool, root=None, parent=None):\n        super().__init__(node, attributeDesc, isOutput, root, parent)\n        self._visible = True\n\n    def getGeometriesAsDict(self):\n        \"\"\"\n        Return the geometries values of the children of the shape list attribute.\n        \"\"\"\n        return [shapeAttribute.geometry.getValueAsDict() for shapeAttribute in self.value]\n\n    def getShapesAsDict(self):\n        \"\"\"\n        Return the children of the shape list attribute.\n        \"\"\"\n        return [shapeAttribute.getShapeAsDict() for shapeAttribute in self.value]\n\n    def _getVisible(self) -> bool:\n        \"\"\"\n        Return whether the shape list is visible for display.\n        \"\"\"\n        if self.isLink:\n            return self.inputLink.isVisible\n        return self._visible\n\n    def _setVisible(self, visible: bool):\n        \"\"\"\n        Set the shape visibility for display.\n        \"\"\"\n        if self.isLink:\n            self.inputLink.isVisible = visible\n        else:\n            self._visible = visible\n        for attribute in self.value:\n            if isinstance(attribute, ShapeAttribute):\n                attribute.isVisible = visible\n        self.shapeListChanged.emit()\n\n    # Properties and signals\n    # Emitted when a shape list related property changed.\n    shapeListChanged = Signal()\n    # Whether the shape list is displayable.\n    isVisible = Property(bool, _getVisible, _setVisible, notify=shapeListChanged)\n    # Override hasDisplayableShape property.\n    hasDisplayableShape = Property(bool, lambda self: True, constant=True)\n"
  },
  {
    "path": "meshroom/core/cgroup.py",
    "content": "#!/usr/bin/env python\n\nimport os\n\n\n# Try to retrieve limits of memory for the current process' cgroup\ndef getCgroupMemorySize():\n\n    # First of all, get pid of process\n    pid = os.getpid()\n\n    # Get cgroup associated with pid\n    filename = f\"/proc/{pid}/cgroup\"\n\n    cgroup = None\n    try:\n        with open(filename) as f:\n\n            # cgroup file is a ':' separated table\n            # lookup a line where the second field is \"memory\"\n            lines = f.readlines()\n            for line in lines:\n                tokens = line.rstrip(\"\\r\\n\").split(\":\")\n                if len(tokens) < 3:\n                    continue\n                if tokens[1] == \"memory\":\n                    cgroup = tokens[2]\n    except OSError:\n        pass\n\n    if cgroup is None:\n        return -1\n\n    size = -1\n    filename = f\"/sys/fs/cgroup/memory/{cgroup}/memory.limit_in_bytes\"\n    try:\n        with open(filename) as f:\n            value = f.read().rstrip(\"\\r\\n\")\n            if value.isnumeric():\n                size = int(value)\n    except OSError:\n        pass\n\n    return size\n\n\ndef parseNumericList(numericListString):\n\n    nList = []\n    for item in numericListString.split(','):\n        if '-' in item:\n            start, end = item.split('-')\n            start = int(start)\n            end = int(end)\n            nList.extend(range(start, end + 1))\n        else:\n            value = int(item)\n            nList.append(value)\n\n    return nList\n\n\n# Try to retrieve limits of cores for the current process' cgroup\ndef getCgroupCpuCount():\n\n    # First of all, get pid of process\n    pid = os.getpid()\n\n    # Get cgroup associated with pid\n    filename = f\"/proc/{pid}/cgroup\"\n\n    cgroup = None\n    try:\n        with open(filename) as f:\n\n            # cgroup file is a ':' separated table\n            # lookup a line where the second field is \"memory\"\n            lines = f.readlines()\n            for line in lines:\n                tokens = line.rstrip(\"\\r\\n\").split(\":\")\n                if len(tokens) < 3:\n                    continue\n                if tokens[1] == \"cpuset\":\n                    cgroup = tokens[2]\n    except OSError:\n        pass\n\n    if cgroup is None:\n        return -1\n\n    size = -1\n    filename = f\"/sys/fs/cgroup/cpuset/{cgroup}/cpuset.cpus\"\n    try:\n        with open(filename) as f:\n            value = f.read().rstrip(\"\\r\\n\")\n            nlist = parseNumericList(value)\n            size = len(nlist)\n\n    except OSError:\n        pass\n\n    return size\n"
  },
  {
    "path": "meshroom/core/desc/__init__.py",
    "content": "from .attribute import (\n    Attribute,\n    BoolParam,\n    ChoiceParam,\n    ColorParam,\n    File,\n    FloatParam,\n    GroupAttribute,\n    IntParam,\n    ListAttribute,\n    PushButtonParam,\n    StringParam,\n    ValueTypeErrors,\n)\nfrom .geometryAttribute import (\n    Geometry,\n    Size2d,\n    Vec2d,\n)\nfrom .shapeAttribute import (\n    Shape,\n    ShapeList,\n    Point2d,\n    Line2d,\n    Rectangle,\n    Circle\n)\nfrom .computation import (\n    DynamicNodeSize,\n    Level,\n    MultiDynamicNodeSize,\n    Parallelization,\n    Range,\n    StaticNodeSize,\n)\nfrom .node import (\n    AVCommandLineNode,\n    BaseNode,\n    BackdropNode,\n    CommandLineNode,\n    InitNode,\n    InputNode,\n    InternalAttributesFactory,\n    MrNodeType,\n    Node,\n)\n"
  },
  {
    "path": "meshroom/core/desc/attribute.py",
    "content": "import ast\nimport os\nimport re\nfrom collections.abc import Iterable\nfrom enum import auto, Enum\n\nfrom meshroom.common import BaseObject, JSValue, Property, Variant, VariantList, strtobool, deprecated\n\n\n# Pre-compile regexes for better performance on repeated calls\n_ACRONYM_RE = re.compile(r'([A-Z]+)([A-Z][a-z])')\n_CAMEL_CASE_RE = re.compile(r'([a-z\\d])([A-Z])')\n_SPLIT_RE = re.compile(r'[_\\s]+')\n\ndef convertToLabel(name: str) -> str:\n    \"\"\"Convert a camelCase or snake_case attribute name into a human-readable label.\n    \n    Examples:\n        >>> convertToLabel('camelCase')\n        'Camel Case'\n        >>> convertToLabel('snake_case')\n        'Snake Case'\n        >>> convertToLabel('myURLParser')\n        'My URL Parser'\n        >>> convertToLabel('mixed_caseExample')\n        'Mixed Case Example'\n        >>> convertToLabel('')\n        ''\n    \"\"\"\n    if not name:\n        return ''\n    \n    # Handle consecutive uppercase letters (e.g. 'URL', 'HTTP')\n    name = _ACRONYM_RE.sub(r'\\1 \\2', name)\n    # Insert space between camelCase boundaries\n    name = _CAMEL_CASE_RE.sub(r'\\1 \\2', name)\n    # Split on underscores or spaces\n    words = _SPLIT_RE.split(name)\n    \n    # Preserve uppercase acronyms, capitalize others\n    return ' '.join(\n        word if word.isupper() else word.capitalize()\n        for word in words\n        if word\n    )\n\nclass ValueTypeErrors(Enum):\n    NONE = auto()  # No error\n    TYPE = auto()  # Invalid type\n    RANGE = auto()  # Invalid range\n    DYNAMIC_OUTPUT = auto()  # Dynamic output not supported\n\n\"\"\"\nThis object is used in group/commandLineGroup to check if\nthe parameter has been set by the user (None is a valid parameter passed value)\n\"\"\"\n_setParamSentinel = object()\n\nclass Attribute(BaseObject):\n    \"\"\"\n    \"\"\"\n\n    def __init__(self, name, label, description, value, advanced, semantic, commandLineGroup, enabled,\n                 keyable=False, keyType=None, invalidate=True, uidIgnoreValue=None,\n                 validValue=True, errorMessage=\"\", visible=True, exposed=False):\n        super(Attribute, self).__init__()\n        self._name = name\n        self._label = convertToLabel(name) if label is None else label\n        self._description = \"\" if description is None else description\n        self._value = value\n        self._keyable = keyable\n        self._keyType = keyType\n        self._commandLineGroup = commandLineGroup\n        self._advanced = advanced\n        self._enabled = enabled\n        self._invalidate = invalidate\n        self._semantic = semantic\n        self._uidIgnoreValue = uidIgnoreValue\n        self._validValue = validValue\n        self._errorMessage = errorMessage\n        self._visible = visible\n        self._exposed = exposed\n        self._isExpression = (isinstance(self._value, str) and \"{\" in self._value) \\\n            or callable(self._value)\n        self._isDynamicValue = (self._value is None)\n        self._valueType = None\n\n    def getInstanceType(self):\n        \"\"\" Return the correct Attribute instance corresponding to the description. \"\"\"\n        # Import within the method to prevent cyclic dependencies\n        from meshroom.core.attribute import Attribute\n        return Attribute\n\n    def validateValue(self, value):\n        \"\"\" Return validated/conformed 'value'. Need to be implemented in derived classes.\n\n        Raises:\n            ValueError: if value does not have the proper type\n        \"\"\"\n        raise NotImplementedError(\"Attribute.validateValue is an abstract function that should be \"\n                                  \"implemented in the derived class.\")\n\n    def validateKeyValues(self, keyValues):\n        \"\"\" Return validated/conformed 'keyValues'.\n\n        Raises:\n            ValueError: if a value does not have the proper type\n        \"\"\"\n        return isinstance(keyValues, dict) and \\\n               all(isinstance(k, str) and self.validateValue(v) for k,v in keyValues.items())\n\n    def checkValueTypes(self):\n        \"\"\" Returns the attribute's name if the default value's type is invalid or if the range's type (when available)\n        is invalid, empty string otherwise.\n\n        Returns:\n            string: the attribute's name if the default value's or range's type is invalid, empty string otherwise\n        \"\"\"\n        raise NotImplementedError(\"Attribute.checkValueTypes is an abstract function that should be implemented in the \"\n                                  \"derived class.\")\n\n    def matchDescription(self, value, strict=True):\n        \"\"\" Returns whether the value perfectly match attribute's description.\n\n        Args:\n            value: the value\n            strict: strict test for the match (for instance, regarding a group with some parameter changes)\n        \"\"\"\n        try:\n            if self._keyable:\n                self.validateKeyValues(value)\n            else:\n                self.validateValue(value)\n        except ValueError:\n            return False\n        return True\n\n    name = Property(str, lambda self: self._name, constant=True)\n    label = Property(str, lambda self: self._label, constant=True)\n    description = Property(str, lambda self: self._description, constant=True)\n    value = Property(Variant, lambda self: self._value, constant=True)\n    # isExpression:\n    #   The default value of the attribute's descriptor is a static string expression that should be evaluated at runtime.\n    #   This property only makes sense for output attributes.\n    isExpression = Property(bool, lambda self: self._isExpression, constant=True)\n    # isDynamicValue\n    #   The default value of the attribute's descriptor is None, so it is not an input value,\n    #   but an output value that is computed during the Node's process execution.\n    isDynamicValue = Property(bool, lambda self: self._isDynamicValue, constant=True)\n    # keyable:\n    #   Whether the attribute can have a distinct value per key.\n    #   By default, atribute value is not keyable.\n    keyable = Property(bool, lambda self: self._keyable, constant=True)\n    # keyType:\n    #   The type of key corresponding to the attribute value.\n    #   This property only makes sense for keyable attributes.\n    keyType = Property(str, lambda self: self._keyType, constant=True)\n    commandLineGroup = Property(str, lambda self: self._commandLineGroup, constant=True)\n    advanced = Property(bool, lambda self: self._advanced, constant=True)\n    enabled = Property(Variant, lambda self: self._enabled, constant=True)\n    invalidate = Property(Variant, lambda self: self._invalidate, constant=True)\n    semantic = Property(str, lambda self: self._semantic, constant=True)\n    uidIgnoreValue = Property(Variant, lambda self: self._uidIgnoreValue, constant=True)\n    validValue = Property(Variant, lambda self: self._validValue, constant=True)\n    errorMessage = Property(str, lambda self: self._errorMessage, constant=True)\n    # visible:\n    #   The attribute is not displayed in the Graph Editor if False but still visible in the Node Editor.\n    #   This property is useful to hide some attributes that are not relevant for the user.\n    visible = Property(bool, lambda self: self._visible, constant=True)\n    # exposed:\n    #   The attribute is exposed in the upper part of the node in the Graph Editor.\n    #   By default, all file attributes are exposed.\n    exposed = Property(bool, lambda self: self._exposed, constant=True)\n    type = Property(str, lambda self: self.__class__.__name__, constant=True)\n    # instanceType\n    #   Attribute instance corresponding to the description\n    instanceType = Property(Variant, lambda self: self.getInstanceType(), constant=True)\n\n\nclass ListAttribute(Attribute):\n    \"\"\" A list of Attributes \"\"\"\n    @deprecated.depreciateParam(\"group\", \"Param 'group' on {name} should not be used anymore. Please use 'commandLineGroup' instead\")\n    def __init__(self, elementDesc, name, label=None, description=None, group=\"allParams\", commandLineGroup=_setParamSentinel, \n                 advanced=False, semantic=\"\", enabled=True, joinChar=\" \", visible=True, exposed=False):\n        \"\"\"\n        :param elementDesc: the Attribute description of elements to store in that list\n        \"\"\"\n        self._elementDesc = elementDesc\n        self._joinChar = joinChar\n        commandLineGroup = commandLineGroup if commandLineGroup is not _setParamSentinel else group\n        \n        super(ListAttribute, self).__init__(name=name, label=label, description=description, value=[],\n                                            invalidate=False, commandLineGroup=commandLineGroup, advanced=advanced, semantic=semantic,\n                                            enabled=enabled, visible=visible, exposed=exposed)\n\n    def getInstanceType(self):\n        # Import within the method to prevent cyclic dependencies\n        from meshroom.core.attribute import ListAttribute\n        return ListAttribute\n\n    def validateValue(self, value):\n        if value is None:\n            return value\n        if JSValue is not None and isinstance(value, JSValue):\n            # Note: we could use isArray(), property(\"length\").toInt() to retrieve all values\n            raise ValueError(\"ListAttribute.validateValue: cannot recognize QJSValue. \"\n                             \"Please, use JSON.stringify(value) in QML.\")\n        if isinstance(value, str):\n            # Alternative solution to set values from QML is to convert values to JSON string\n            # In this case, it works with all data types\n            value = ast.literal_eval(value)\n\n        if not isinstance(value, (list, tuple)):\n            raise ValueError(f\"ListAttribute only supports list/tuple input values \"\n                             f\"(param: {self.name}, value: {value}, type: {type(value)})\")\n        return value\n\n    def checkValueTypes(self):\n        return self.elementDesc.checkValueTypes()\n\n    def matchDescription(self, value, strict=True):\n        \"\"\" Check that 'value' content matches ListAttribute's element description. \"\"\"\n        if not super(ListAttribute, self).matchDescription(value, strict):\n            return False\n        # list must be homogeneous: only test first element\n        if value:\n            return self._elementDesc.matchDescription(value[0], strict)\n        return True\n\n    elementDesc = Property(Attribute, lambda self: self._elementDesc, constant=True)\n    invalidate = Property(Variant, lambda self: self.elementDesc.invalidate, constant=True)\n    joinChar = Property(str, lambda self: self._joinChar, constant=True)\n\n\nclass GroupAttribute(Attribute):\n    \"\"\" A macro Attribute composed of several Attributes \"\"\"\n    @deprecated.depreciateParam(\"group\", \"Param 'group' on {name} should not be used anymore. Please use 'commandLineGroup' instead\")\n    def __init__(self, items, name, label=None, description=None, group=\"allParams\", commandLineGroup=_setParamSentinel, \n                 advanced=False, semantic=\"\",  enabled=True, joinChar=\" \", brackets=None, visible=True,\n                 exposed=False):\n        \"\"\"\n        :param items: the description of the Attributes composing this group\n        \"\"\"\n        self._items = items\n        self._joinChar = joinChar\n        self._brackets = brackets\n        commandLineGroup = commandLineGroup if commandLineGroup is not _setParamSentinel else group\n\n        super(GroupAttribute, self).__init__(name=name, label=label, description=description, value={},\n                                             commandLineGroup=commandLineGroup, advanced=advanced, invalidate=False, semantic=semantic,\n                                             enabled=enabled, visible=visible, exposed=exposed)\n\n    def getInstanceType(self):\n        # Import within the method to prevent cyclic dependencies\n        from meshroom.core.attribute import GroupAttribute\n        return GroupAttribute\n\n    def validateValue(self, value):\n        \"\"\" Ensure value is compatible with the group description and convert value if needed. \"\"\"\n        if value is None:\n            return value\n        if JSValue is not None and isinstance(value, JSValue):\n            # Note: we could use isArray(), property(\"length\").toInt() to retrieve all values\n            raise ValueError(\"GroupAttribute.validateValue: cannot recognize QJSValue. \"\n                             \"Please, use JSON.stringify(value) in QML.\")\n        if isinstance(value, str):\n            # Alternative solution to set values from QML is to convert values to JSON string\n            # In this case, it works with all data types\n            value = ast.literal_eval(value)\n\n        if isinstance(value, dict):\n            # invalidKeys = set(value.keys()).difference([attr.name for attr in self._items])\n            # if invalidKeys:\n            #     raise ValueError(f\"Value contains key that does not match group description: \"\n            #                      f\"{invalidKeys}\")\n            if self._items and value.keys():\n                commonKeys = set(value.keys()).intersection([attr.name for attr in self._items])\n                if not commonKeys:\n                    raise ValueError(f\"Value contains no key that matches with the group \"\n                                     f\"description (name={self.name}, values={value.keys()}, \"\n                                     f\"desc={[attr.name for attr in self._items]})\")\n        elif isinstance(value, (list, tuple, set)):\n            if len(value) != len(self._items):\n                raise ValueError(f\"Value contains incoherent number of values: \"\n                                 f\"desc size: {len(self._items)}, value size: {len(value)}\")\n        else:\n            raise ValueError(f\"GroupAttribute only supports dict/list/tuple input values \"\n                             f\"(param: {self.name}, value: {value}, type: {type(value)})\")\n\n        return value\n\n    def checkValueTypes(self):\n        \"\"\" Check the default value's and range's (if available) type of every attribute contained in the group\n        (including nested attributes).\n\n        Returns an empty string if all the attributes' types are valid, or concatenates the names of the attributes in\n        the group with invalid types.\n        \"\"\"\n        invalidParams = []\n        for attr in self.items:\n            name, error = attr.checkValueTypes()\n            if name:\n                invalidParams.append(name)\n        if invalidParams:\n            # In group \"group\", if parameters \"x\" and \"y\" (with \"y\" in nested group \"subgroup\") are invalid, the\n            # returned string will be: \"group:x, group:subgroup:y\"\n            return self.name + \":\" + str(\", \" + self.name + \":\").join(invalidParams), error\n        return \"\", ValueTypeErrors.NONE\n\n    def matchDescription(self, value, strict=True):\n        \"\"\"\n        Check that 'value' contains the exact same set of keys as GroupAttribute's group description\n        and that every child value match corresponding child attribute description.\n\n        Args:\n            value: the value\n            strict: strict test for the match (for instance, regarding a group with some parameter changes)\n        \"\"\"\n        if not super(GroupAttribute, self).matchDescription(value):\n            return False\n        attrMap = {attr.name: attr for attr in self._items}\n\n        matchCount = 0\n        for k, v in value.items():\n            # each child value must match corresponding child attribute description\n            if k in attrMap and attrMap[k].matchDescription(v, strict):\n                matchCount += 1\n\n        if strict:\n            return matchCount == len(value.items()) == len(self._items)\n\n        return matchCount > 0\n\n    def retrieveChildrenInvalidations(self):\n        allInvalidations = []\n        for desc in self._items:\n            allInvalidations.append(desc.invalidate)\n        return allInvalidations\n\n    items = Property(Variant, lambda self: self._items, constant=True)\n    invalidate = Property(Variant, retrieveChildrenInvalidations, constant=True)\n    joinChar = Property(str, lambda self: self._joinChar, constant=True)\n    brackets = Property(str, lambda self: self._brackets, constant=True)\n\n\nclass Param(Attribute):\n    \"\"\"\n    \"\"\"\n    def __init__(self, name, label, description, value, commandLineGroup, advanced, semantic, enabled,\n                 keyable=False, keyType=None, invalidate=True, uidIgnoreValue=None,\n                 validValue=True, errorMessage=\"\", visible=True, exposed=False):\n        super(Param, self).__init__(name=name, label=label, description=description, value=value,\n                                    keyable=keyable, keyType=keyType, commandLineGroup=commandLineGroup, advanced=advanced,\n                                    enabled=enabled, invalidate=invalidate, semantic=semantic,\n                                    uidIgnoreValue=uidIgnoreValue, validValue=validValue,\n                                    errorMessage=errorMessage, visible=visible, exposed=exposed)\n\n\nclass File(Attribute):\n    \"\"\"\n    \"\"\"\n    @deprecated.depreciateParam(\"group\", \"Param 'group' on {name} should not be used anymore. Please use 'commandLineGroup' instead\")\n    def __init__(self, name, label=None, description=None, value=None, group=\"allParams\", commandLineGroup=_setParamSentinel, \n                 advanced=False, invalidate=True, semantic=\"\", enabled=True, visible=True, exposed=True):\n        \n        commandLineGroup = commandLineGroup if commandLineGroup is not _setParamSentinel else group\n\n        super(File, self).__init__(name=name, label=label, description=description, value=value, \n                                   commandLineGroup=commandLineGroup, advanced=advanced, enabled=enabled, \n                                   invalidate=invalidate, semantic=semantic, visible=visible, exposed=exposed)\n        self._valueType = str\n\n    def validateValue(self, value):\n        if value is None:\n            return value\n        if not isinstance(value, str):\n            raise ValueError(f\"File only supports string input (param: {self.name}, value: \"\n                             f\"{value}, type: {type(value)})\")\n        return os.path.normpath(value).replace(\"\\\\\", \"/\") if value else \"\"\n\n    def checkValueTypes(self):\n        if self.value is None:\n            return \"\", ValueTypeErrors.NONE\n        # Some File values are functions generating a string: check whether the value is a string or if it\n        # is a function (but there is no way to check that the function's output is indeed a string)\n        if not isinstance(self.value, str) and not callable(self.value):\n            return self.name, ValueTypeErrors.TYPE\n        return \"\", ValueTypeErrors.NONE\n\n\nclass BoolParam(Param):\n    \"\"\"\n    \"\"\"\n    @deprecated.depreciateParam(\"group\", \"Param 'group' on {name} should not be used anymore. Please use 'commandLineGroup' instead\")\n    def __init__(self, name, label=None, description=None, value=None, keyable=False, keyType=None,\n                 group=\"allParams\", commandLineGroup=_setParamSentinel, advanced=False, \n                 enabled=True, invalidate=True, semantic=\"\", visible=True, exposed=False):\n        \n        commandLineGroup = commandLineGroup if commandLineGroup is not _setParamSentinel else group\n\n        super(BoolParam, self).__init__(name=name, label=label, description=description, value=value,\n                                        keyable=keyable, keyType=keyType, commandLineGroup=commandLineGroup, \n                                        advanced=advanced, enabled=enabled, invalidate=invalidate, \n                                        semantic=semantic, visible=visible, exposed=exposed)\n        self._valueType = bool\n\n    def validateValue(self, value):\n        if value is None:\n            return value\n        try:\n            if isinstance(value, str):\n                return bool(strtobool(value))\n            return bool(value)\n        except Exception:\n            raise ValueError(f\"BoolParam only supports bool value (param: {self.name}, \"\n                             f\"value: {value}, type: {type(value)})\")\n\n    def checkValueTypes(self):\n        if self.value is None:\n            return \"\", ValueTypeErrors.NONE\n        if not isinstance(self.value, bool):\n            return self.name, ValueTypeErrors.TYPE\n        return \"\", ValueTypeErrors.NONE\n\n\nclass IntParam(Param):\n    \"\"\"\n    \"\"\"\n    @deprecated.depreciateParam(\"group\", \"Param 'group' on {name} should not be used anymore. Please use 'commandLineGroup' instead\")\n    def __init__(self, name, label=None, description=None, value=None, range=None, keyable=False, keyType=None,\n                 group=\"allParams\", commandLineGroup=_setParamSentinel, advanced=False, enabled=True, \n                 invalidate=True, semantic=\"\", validValue=True, errorMessage=\"\", visible=True, exposed=False):\n        self._range = range\n\n        commandLineGroup = commandLineGroup if commandLineGroup is not _setParamSentinel else group\n\n        super(IntParam, self).__init__(name=name, label=label, description=description, value=value,\n                                       keyable=keyable, keyType=keyType, commandLineGroup=commandLineGroup, \n                                       advanced=advanced, enabled=enabled, invalidate=invalidate, \n                                       semantic=semantic, validValue=validValue, errorMessage=errorMessage,\n                                       visible=visible, exposed=exposed)\n        self._valueType = int\n\n    def validateValue(self, value):\n        if value is None:\n            return value\n        # Handle unsigned int values that are translated to int by shiboken and may overflow\n        try:\n            return int(value)\n        except Exception:\n            raise ValueError(f\"IntParam only supports int value (param: {self.name}, value: \"\n                             f\"{value}, type: {type(value)})\")\n\n    def checkValueTypes(self):\n        if self.value is None:\n            return \"\", ValueTypeErrors.NONE\n        if not isinstance(self.value, int):\n            return self.name, ValueTypeErrors.TYPE\n        if (self.range and not all([isinstance(r, int) for r in self.range])):\n            return self.name, ValueTypeErrors.RANGE\n        return \"\", ValueTypeErrors.NONE\n\n    range = Property(VariantList, lambda self: self._range, constant=True)\n\n\nclass FloatParam(Param):\n    \"\"\"\n    \"\"\"\n    @deprecated.depreciateParam(\"group\", \"Param 'group' on {name} should not be used anymore. Please use 'commandLineGroup' instead\")\n    def __init__(self, name, label=None, description=None, value=None, range=None, keyable=False, keyType=None,\n                 group=\"allParams\", commandLineGroup=_setParamSentinel, advanced=False, enabled=True, \n                 invalidate=True, semantic=\"\", validValue=True, errorMessage=\"\", visible=True, exposed=False):\n        self._range = range\n        commandLineGroup = commandLineGroup if commandLineGroup is not _setParamSentinel else group\n\n        super(FloatParam, self).__init__(name=name, label=label, description=description, value=value,\n                                         keyable=keyable, keyType=keyType, commandLineGroup=commandLineGroup, \n                                         advanced=advanced, enabled=enabled, invalidate=invalidate, \n                                         semantic=semantic, validValue=validValue, errorMessage=errorMessage,\n                                         visible=visible, exposed=exposed)\n        self._valueType = float\n\n    def validateValue(self, value):\n        if value is None:\n            return value\n        try:\n            return float(value)\n        except Exception:\n            raise ValueError(f\"FloatParam only supports float value (param: {self.name}, value: \"\n                             f\"{value}, type:{type(value)})\")\n\n    def checkValueTypes(self):\n        if self.value is None:\n            return \"\", ValueTypeErrors.NONE\n        if not isinstance(self.value, float):\n            return self.name, ValueTypeErrors.TYPE\n        if (self.range and not all([isinstance(r, float) for r in self.range])):\n            return self.name, ValueTypeErrors.RANGE\n        return \"\", ValueTypeErrors.NONE\n\n    range = Property(VariantList, lambda self: self._range, constant=True)\n\n\nclass PushButtonParam(Param):\n    \"\"\"\n    \"\"\"\n    @deprecated.depreciateParam(\"group\", \"Param 'group' on {name} should not be used anymore. Please use 'commandLineGroup' instead\")\n    def __init__(self, name, label=None, description=None, group=\"allParams\", commandLineGroup=_setParamSentinel, \n                 advanced=False, enabled=True, invalidate=True, semantic=\"\", visible=True, exposed=False):\n        \n        commandLineGroup = commandLineGroup if commandLineGroup is not _setParamSentinel else group\n\n        super(PushButtonParam, self).__init__(name=name, label=label, description=description, value=None,\n                                              commandLineGroup=commandLineGroup, advanced=advanced, enabled=enabled, \n                                              invalidate=invalidate, semantic=semantic, visible=visible, \n                                              exposed=exposed)\n        self._valueType = None\n\n    def getInstanceType(self):\n        # Import within the method to prevent cyclic dependencies\n        from meshroom.core.attribute import PushButtonParam\n        return PushButtonParam\n\n    def validateValue(self, value):\n        return value\n\n    def checkValueTypes(self):\n        return \"\", ValueTypeErrors.NONE\n\n\nclass ChoiceParam(Param):\n    \"\"\"\n    ChoiceParam is an Attribute that allows to choose a value among a list of possible values.\n\n    When using `exclusive=True`, the value is a single element of the list of possible values.\n    When using `exclusive=False`, the value is a list of elements of the list of possible values.\n\n    Despite this being the standard behavior, ChoiceParam also supports custom value: it is possible to set any value,\n    even outside list of possible values.\n\n    The list of possible values on a ChoiceParam instance can be overriden at runtime.\n    If those changes needs to be persisted, `saveValuesOverride` should be set to True.\n    \"\"\"\n\n    # Keys for values override serialization schema (saveValuesOverride=True).\n    _OVERRIDE_SERIALIZATION_KEY_VALUE = \"__ChoiceParam_value__\"\n    _OVERRIDE_SERIALIZATION_KEY_VALUES = \"__ChoiceParam_values__\"\n\n    @deprecated.depreciateParam(\"group\", \"Param 'group' on {name} should not be used anymore. Please use 'commandLineGroup' instead\")\n    def __init__(self, name: str, label=None, description=None, value=None, values=None, exclusive=True, saveValuesOverride=False,\n                 group=\"allParams\", commandLineGroup=_setParamSentinel, joinChar=\" \", advanced=False, enabled=True, \n                 invalidate=True, semantic=\"\", validValue=True, errorMessage=\"\", visible=True, exposed=False):\n\n        commandLineGroup = commandLineGroup if commandLineGroup is not _setParamSentinel else group\n\n        super(ChoiceParam, self).__init__(name=name, label=label, description=description, value=value,\n                                          commandLineGroup=commandLineGroup, advanced=advanced, enabled=enabled, \n                                          invalidate=invalidate, semantic=semantic, validValue=validValue, \n                                          errorMessage=errorMessage, visible=visible, exposed=exposed)\n        self._values = values if values is not None else []\n        self._saveValuesOverride = saveValuesOverride\n        self._exclusive = exclusive\n        self._joinChar = joinChar\n        if self._values:\n            # Look at the type of the first element of the possible values\n            self._valueType = type(self._values[0])\n        elif not exclusive and self._value is not None:\n            # Possible values may be defined later, so use the value to define the type.\n            # if non exclusive, it is a list\n            self._valueType = type(self._value[0])\n        else:\n            self._valueType = type(self._value)\n\n    def getInstanceType(self):\n        # Import within the method to prevent cyclic dependencies\n        from meshroom.core.attribute import ChoiceParam\n        return ChoiceParam\n\n    def conformValue(self, value):\n        \"\"\" Conform 'value' to the correct type and check for its validity \"\"\"\n        # We do not check that the value is in the list of values.\n        # This allows to have a value that is not in the list of possible values.\n        return self._valueType(value)\n\n    def validateValue(self, value):\n        if value is None:\n            return value\n\n        serializedWithValuesOverride = isinstance(value, dict)\n        if serializedWithValuesOverride:\n            value = value[ChoiceParam._OVERRIDE_SERIALIZATION_KEY_VALUE]\n\n        if self.exclusive:\n            return self.conformValue(value)\n\n        if isinstance(value, str):\n            value = value.split(',')\n\n        if not isinstance(value, Iterable):\n            raise ValueError(f\"Non-exclusive ChoiceParam value should be iterable (param: \"\n                             f\"{self.name}, value: {value}, type: {type(value)}).\")\n\n        return [self.conformValue(v) for v in value]\n\n    def checkValueTypes(self):\n        # Check that the values have been provided as a list\n        if not isinstance(self._values, list):\n            return self.name, ValueTypeErrors.TYPE\n\n        # None value is valid (dynamic default)\n        if self._value is None:\n            return \"\", ValueTypeErrors.NONE\n\n        # If the choices are not exclusive, check that 'value' is a list, and check that it does not contain values that\n        # are not available\n        elif not self.exclusive and (not isinstance(self._value, list) or\n                                     not all(val in self._values for val in self._value)):\n            return self.name, ValueTypeErrors.RANGE\n\n        # If the choices are exclusive, the value should NOT be a list but it can contain any value that is not in the\n        # list of possible ones\n        elif self.exclusive and isinstance(self._value, list):\n            return self.name, ValueTypeErrors.TYPE\n\n        return \"\", ValueTypeErrors.NONE\n\n    values = Property(VariantList, lambda self: self._values, constant=True)\n    exclusive = Property(bool, lambda self: self._exclusive, constant=True)\n    joinChar = Property(str, lambda self: self._joinChar, constant=True)\n\n\nclass StringParam(Param):\n    \"\"\"\n    \"\"\"\n    @deprecated.depreciateParam(\"group\", \"Param 'group' on {name} should not be used anymore. Please use 'commandLineGroup' instead\")\n    def __init__(self, name, label=None, description=None, value=None, group=\"allParams\", commandLineGroup=_setParamSentinel, \n                 advanced=False, enabled=True, invalidate=True, semantic=\"\", uidIgnoreValue=None, validValue=True, \n                 errorMessage=\"\", visible=True, exposed=False):\n\n        commandLineGroup = commandLineGroup if commandLineGroup is not _setParamSentinel else group\n\n        super(StringParam, self).__init__(name=name, label=label, description=description, value=value,\n                                          commandLineGroup=commandLineGroup, advanced=advanced, enabled=enabled, \n                                          invalidate=invalidate, semantic=semantic, uidIgnoreValue=uidIgnoreValue, \n                                          validValue=validValue, errorMessage=errorMessage, visible=visible, \n                                          exposed=exposed)\n        self._valueType = str\n\n    def validateValue(self, value):\n        if value is None:\n            return value\n        if not isinstance(value, str):\n            raise ValueError(f\"StringParam value should be a string (param: \"\n                             f\"{self.name}, value: {value}, type: {type(value)})\")\n        return value\n\n    def checkValueTypes(self):\n        if self.value is None:\n            return \"\", ValueTypeErrors.NONE\n        if not isinstance(self.value, str):\n            return self.name, ValueTypeErrors.TYPE\n        return \"\", ValueTypeErrors.NONE\n\n\nclass ColorParam(Param):\n    \"\"\"\n    \"\"\"\n    @deprecated.depreciateParam(\"group\", \"Param 'group' on {name} should not be used anymore. Please use 'commandLineGroup' instead\")\n    def __init__(self, name, label=None, description=None, value=None, group=\"allParams\", commandLineGroup=_setParamSentinel, \n                 advanced=False, enabled=True, invalidate=True, semantic=\"\", visible=True, exposed=False):\n        \n        commandLineGroup = commandLineGroup if commandLineGroup is not _setParamSentinel else group\n\n        super(ColorParam, self).__init__(name=name, label=label, description=description, value=value,\n                                         commandLineGroup=commandLineGroup, advanced=advanced, enabled=enabled, \n                                         invalidate=invalidate, semantic=semantic, visible=visible, exposed=exposed)\n        self._valueType = str\n\n    def validateValue(self, value):\n        if value is None:\n            return value\n        if not isinstance(value, str) or len(value.split(\" \")) > 1:\n            raise ValueError(f\"ColorParam value should be a string containing either an SVG name \"\n                             f\"or an hexadecimal color code (param: {self.name}, value: {value}, \"\n                             f\"type: {type(value)})\")\n        return value\n\n    def checkValueTypes(self):\n        if self.value is None:\n            return \"\", ValueTypeErrors.NONE\n        if not isinstance(self.value, str):\n            return self.name, ValueTypeErrors.TYPE\n        return \"\", ValueTypeErrors.NONE\n"
  },
  {
    "path": "meshroom/core/desc/computation.py",
    "content": "import math\nfrom enum import IntEnum\n\nfrom .attribute import ListAttribute, IntParam\n\n\nclass Level(IntEnum):\n    SCRIPT=-1\n    NONE = 0\n    NORMAL = 1\n    INTENSIVE = 2\n    EXTREME = 3\n\n\nclass Range:\n    def __init__(self, iteration=0, blockSize=0, fullSize=0, nbBlocks=0):\n        self.iteration = iteration\n        self.blockSize = blockSize\n        self.fullSize = fullSize\n        self.nbBlocks = nbBlocks\n\n    @property\n    def start(self):\n        return self.iteration * self.blockSize\n\n    @property\n    def effectiveBlockSize(self):\n        remaining = (self.fullSize - self.start) + 1\n        return self.blockSize if remaining >= self.blockSize else remaining\n\n    @property\n    def end(self):\n        return self.start + self.effectiveBlockSize\n\n    @property\n    def last(self):\n        return self.end - 1\n\n    def toDict(self):\n        return {\n            \"rangeIteration\": self.iteration,\n            \"rangeStart\": self.start,\n            \"rangeEnd\": self.end,\n            \"rangeLast\": self.last,\n            \"rangeBlockSize\": self.blockSize,\n            \"rangeEffectiveBlockSize\": self.effectiveBlockSize,\n            \"rangeFullSize\": self.fullSize,\n            \"rangeBlocksCount\": self.nbBlocks\n            }\n\n    def __repr__(self):\n        return f\"<Range {self.iteration}({self.blockSize})/{self.nbBlocks}({self.fullSize})>\"\n\n\nclass Parallelization:\n    def __init__(self, staticNbBlocks=0, blockSize=0):\n        self.staticNbBlocks = staticNbBlocks\n        self.blockSize = blockSize\n\n    def getSizes(self, node):\n        \"\"\"\n        Args:\n            node:\n        Returns: (blockSize, fullSize, nbBlocks)\n        \"\"\"\n        size = node.size\n        if self.blockSize:\n            nbBlocks = int(math.ceil(float(size) / float(self.blockSize)))\n            return self.blockSize, size, nbBlocks\n        if self.staticNbBlocks:\n            return 1, self.staticNbBlocks, self.staticNbBlocks\n        return None\n\n    def getRange(self, node, iteration):\n        blockSize, fullSize, nbBlocks = self.getSizes(node)\n        return Range(iteration=iteration, blockSize=blockSize, fullSize=fullSize, nbBlocks=nbBlocks)\n\n    def getRanges(self, node):\n        blockSize, fullSize, nbBlocks = self.getSizes(node)\n        ranges = []\n        for i in range(nbBlocks):\n            ranges.append(Range(iteration=i, blockSize=blockSize, fullSize=fullSize, nbBlocks=nbBlocks))\n        return ranges\n\n\nclass DynamicNodeSize(object):\n    \"\"\"\n    DynamicNodeSize expresses a dependency to an input attribute to define\n    the size of a Node in terms of individual tasks for parallelization.\n    If the attribute is a link to another node, Node's size will be the same as this connected node.\n    If the attribute is a ListAttribute, Node's size will be the size of this list.\n    \"\"\"\n    def __init__(self, param):\n        self._param = param\n\n    def __call__(self, node):\n        param = node.attribute(self._param)\n        # Link: use linked node's size\n        if param.isLink:\n            return param.inputLink.node.size\n        # ListAttribute: use list size\n        if isinstance(param.desc, ListAttribute):\n            return len(param)\n        if isinstance(param.desc, IntParam):\n            return param.value\n        return 1\n\n\nclass MultiDynamicNodeSize(object):\n    \"\"\"\n    MultiDynamicNodeSize expresses dependencies to multiple input attributes to\n    define the size of a node in terms of individual tasks for parallelization.\n    Works as DynamicNodeSize and sum the sizes of each dependency.\n    \"\"\"\n    def __init__(self, params):\n        \"\"\"\n        Args:\n            params (list): list of input attributes names\n        \"\"\"\n        assert isinstance(params, (list, tuple))\n        self._params = params\n\n    def __call__(self, node):\n        size = 0\n        for param in self._params:\n            param = node.attribute(param)\n            if param.isLink:\n                size += param.inputLink.node.size\n            elif isinstance(param.desc, ListAttribute):\n                size += len(param)\n            else:\n                size += 1\n        return size\n\n\nclass StaticNodeSize(object):\n    \"\"\"\n    StaticNodeSize expresses a static Node size in terms of individual tasks for parallelization.\n    \"\"\"\n    def __init__(self, size):\n        self._size = size\n\n    def __call__(self, node):\n        return self._size\n"
  },
  {
    "path": "meshroom/core/desc/geometryAttribute.py",
    "content": "from meshroom.core.desc import GroupAttribute, FloatParam\n\n\nclass Geometry(GroupAttribute):\n    \"\"\"\n    Base attribute for all Geometry attribute.\n    Countains several attributes (inherit from GroupAttribute).\n    \"\"\"\n    def __init__(self, items, name, label=None, description=None, commandLineGroup=\"allParams\", advanced=False, semantic=\"\",\n                 enabled=True, visible=True, exposed=False):\n        # GroupAttribute constructor\n        super(Geometry, self).__init__(items=items, name=name, label=label, description=description,\n                                       commandLineGroup=commandLineGroup, advanced=advanced, semantic=semantic,\n                                       enabled=enabled, visible=visible, exposed=exposed)\n\n    def getInstanceType(self):\n        \"\"\"\n        Return the correct Attribute instance corresponding to the description.\n        \"\"\"\n        # Import within the method to prevent cyclic dependencies\n        from meshroom.core.attribute import GeometryAttribute\n        return GeometryAttribute\n\n\nclass Size2d(Geometry):\n    \"\"\"\n    Size2d is a Geometry attribute that allows to specify a 2d size.\n    \"\"\"\n    def __init__(self, name, label=None, description=None, width=None, height=None, widthRange=None, heightRange=None,\n                 keyable=False, keyType=None, commandLineGroup=\"allParams\", advanced=False, semantic=\"\",\n                 enabled=True, visible=True, exposed=False):\n        # Geometry group desciption\n        items = [\n            FloatParam(name=\"width\", label=\"Width\", description=\"Width size.\", value=width, range=widthRange,\n                       keyable=keyable, keyType=keyType, commandLineGroup=commandLineGroup, advanced=advanced,\n                       enabled=enabled, visible=visible, exposed=exposed),\n            FloatParam(name=\"height\", label=\"Height\", description=\"Height size.\", value=height, range=heightRange,\n                       keyable=keyable, keyType=keyType, commandLineGroup=commandLineGroup, advanced=advanced,\n                       enabled=enabled, visible=visible, exposed=exposed)\n        ]\n        # GeometryAttribute constructor\n        super(Size2d, self).__init__(items, name, label, description, commandLineGroup=None, advanced=advanced,\n                                     semantic=semantic, enabled=enabled, visible=visible, exposed=exposed)\n\nclass Vec2d(Geometry):\n    \"\"\"\n    Vec2d is a Geometry attribute that allows to specify a 2d vector.\n    \"\"\"\n    def __init__(self, name, label=None, description=None, x=None, y=None, xRange=None, yRange=None,\n                 keyable=False, keyType=None, commandLineGroup=\"allParams\", advanced=False, semantic=\"\",\n                 enabled=True, visible=True, exposed=False):\n        # Geometry group desciption\n        items = [\n            FloatParam(name=\"x\", label=\"X\", description=\"X coordinate.\", value=x, range=xRange,\n                       keyable=keyable, keyType=keyType, commandLineGroup=commandLineGroup, advanced=advanced,\n                       enabled=enabled, visible=visible, exposed=exposed),\n            FloatParam(name=\"y\", label=\"Y\", description=\"Y coordinate.\", value=y, range=yRange,\n                       keyable=keyable, keyType=keyType, commandLineGroup=commandLineGroup, advanced=advanced,\n                       enabled=enabled, visible=visible, exposed=exposed)\n        ]\n        # GeometryAttribute constructor\n        super(Vec2d, self).__init__(items, name, label, description, commandLineGroup=None, advanced=advanced,\n                                     semantic=semantic, enabled=enabled, visible=visible, exposed=exposed)\n"
  },
  {
    "path": "meshroom/core/desc/node.py",
    "content": "import enum\nfrom inspect import getfile, getattr_static\nfrom pathlib import Path\nimport logging\nimport shlex\nimport shutil\nimport sys\nimport signal\nimport subprocess\n\nimport psutil\n\nimport meshroom\nfrom meshroom.core import cgroup\nfrom meshroom.core.utils import VERBOSE_LEVEL\n\nfrom .computation import Level, StaticNodeSize\nfrom .attribute import Attribute, ChoiceParam, ColorParam, IntParam, StringParam\n\n_MESHROOM_ROOT = Path(meshroom.__file__).parent.parent.as_posix()\n_MESHROOM_COMPUTE = (Path(_MESHROOM_ROOT) / \"bin\" / \"meshroom_compute\").as_posix()\n_MESHROOM_COMPUTE_DEPS = [\"psutil\"]\n\n\n# Handle cleanup\nclass ExitCleanup:\n    \"\"\"\n    Make sure we kill child subprocesses when the main process exits receive SIGTERM.\n    \"\"\"\n\n    def __init__(self):\n        self._subprocesses = []\n        signal.signal(signal.SIGTERM, self.exit)\n\n    def addSubprocess(self, process):\n        logging.debug(f\"[ExitCleanup] Register subprocess {process}\")\n        self._subprocesses.append(process)\n\n    def exit(self, signum, frame):\n        for proc in self._subprocesses:\n            logging.debug(f\"[ExitCleanup] Kill subprocess {proc}\")\n            try:\n                if proc.is_running():\n                    proc.terminate()\n                    proc.wait(timeout=5)\n            except subprocess.TimeoutExpired:\n                proc.kill()\n        sys.exit(0)\n\nexitCleanup = ExitCleanup()\n\n\nclass MrNodeType(enum.Enum):\n    NONE = enum.auto()\n    BASENODE = enum.auto()\n    NODE = enum.auto()\n    COMMANDLINE = enum.auto()\n    INPUT = enum.auto()\n    BACKDROP = enum.auto()\n\n\nclass InternalAttributesFactory:\n    BASIC = [\n        StringParam(\n            name=\"comment\",\n            label=\"Comments\",\n            description=\"User comments describing this specific node instance.\\n\"\n                        \"It is displayed in regular font in the invalidation/comment messages \"\n                        \"tooltip.\",\n            value=\"\",\n            semantic=\"multiline\",\n            invalidate=False,\n        ),\n        StringParam(\n            name=\"label\",\n            label=\"Node's Label\",\n            description=\"Customize the default label (to replace the technical name of the node \"\n                        \"instance).\",\n            value=\"\",\n            invalidate=False,\n        ),\n        ChoiceParam(\n            name=\"nodeDefaultLogLevel\",\n            label=\"Default Logging Level\",\n            description=\"Default logging level for the node (critical, error, warning, info, debug).\",\n            value=\"info\",\n            values=VERBOSE_LEVEL,\n            invalidate=False,\n        ),\n        ColorParam(\n            name=\"color\",\n            label=\"Color\",\n            description=\"Custom color for the node (SVG name or hexadecimal code).\",\n            value=lambda node: getattr(node.nodeDesc, \"color\", \"\"),\n            invalidate=False,\n        )\n    ]\n\n    INVALIDATION = [\n        StringParam(\n            name=\"invalidation\",\n            label=\"Invalidation Message\",\n            description=\"A message that will invalidate the node's output folder.\\n\"\n                        \"This is useful for development, we can invalidate the output of the node \"\n                        \"when we modify the code.\\n\"\n                        \"It is displayed in bold font in the invalidation/comment messages \"\n                        \"tooltip.\",\n            value=\"\",\n            semantic=\"multiline\",\n            advanced=True,\n            uidIgnoreValue=\"\",  # If the invalidation string is empty, it does not participate to the node's UID\n        ),\n    ]\n\n    RESIZABLE = [\n        IntParam(\n            name=\"fontSize\",\n            label=\"Font Size\",\n            description=\"Size of the font used to display the comments.\",\n            value=12,\n            range=(6, 100, 1),\n            invalidate=False,\n        ),\n        ColorParam(\n            name=\"fontColor\",\n            label=\"Font Color\",\n            description=\"Color of the font used to display the comments (SVG name or hexadecimal code).\",\n            value=\"\",\n            invalidate=False,\n        ),\n        IntParam(\n            name=\"nodeWidth\",\n            label=\"Node Width\",\n            description=\"Width of the node in the graph editor.\",\n            value=600,\n            range=None,\n            invalidate=False,\n            enabled=False,  # Hidden\n        ),\n        IntParam(\n            name=\"nodeHeight\",\n            label=\"Node Height\",\n            description=\"Height of the node in the graph editor.\",\n            value=400,\n            range=None,\n            invalidate=False,\n            enabled=False,  # Hidden\n        ),\n    ]\n\n    @classmethod\n    def getInternalAttributes(cls, mrNodeType: MrNodeType) -> list[Attribute]:\n        paramMap = {\n            MrNodeType.NONE: cls.BASIC,\n            MrNodeType.BASENODE: cls.INVALIDATION + cls.BASIC,\n            MrNodeType.NODE: cls.INVALIDATION + cls.BASIC,\n            MrNodeType.COMMANDLINE: cls.INVALIDATION + cls.BASIC,\n            MrNodeType.INPUT: cls.BASIC,\n            MrNodeType.BACKDROP: cls.BASIC + cls.RESIZABLE,\n        }\n\n        return paramMap.get(mrNodeType)\n\n\nclass BaseNode(object):\n    \"\"\"\n    \"\"\"\n    cpu = Level.NORMAL\n    gpu = Level.NONE\n    ram = Level.NORMAL\n    packageName = \"\"\n    color = \"\"\n    _mrNodeType: MrNodeType = MrNodeType.BASENODE\n\n    internalInputs = InternalAttributesFactory.getInternalAttributes(_mrNodeType)\n\n    inputs = []\n    outputs = []\n    size = StaticNodeSize(1)\n    parallelization = None\n    documentation = \"\"\n    category = \"Other\"\n    plugin = None\n    # Licenses required to run the plugin\n    # Only used to select machines on the farm when the node is submitted\n    _licenses = []\n\n    def __init__(self):\n        super(BaseNode, self).__init__()\n        self.hasDynamicOutputAttribute = any(output.isDynamicValue for output in self.outputs)\n        self.sourceCodeFolder = Path(getfile(self.__class__)).parent.resolve().as_posix()\n\n    def getMrNodeType(self):\n        return self._mrNodeType\n\n    @classmethod\n    def resolvedCpu(cls, node):\n        \"\"\" Return the resolved CPU level for the given node instance.\n\n        If `cpu` is a callable, it is called with the node instance as parameter.\n        Otherwise, the static value is returned.\n        \"\"\"\n        return cls.cpu(node) if callable(cls.cpu) else cls.cpu\n\n    @classmethod\n    def resolvedGpu(cls, node):\n        \"\"\" Return the resolved GPU level for the given node instance.\n\n        If `gpu` is a callable, it is called with the node instance as parameter.\n        Otherwise, the static value is returned.\n        \"\"\"\n        return cls.gpu(node) if callable(cls.gpu) else cls.gpu\n\n    @classmethod\n    def resolvedRam(cls, node):\n        \"\"\" Return the resolved RAM level for the given node instance.\n\n        If `ram` is a callable, it is called with the node instance as parameter.\n        Otherwise, the static value is returned.\n        \"\"\"\n        return cls.ram(node) if callable(cls.ram) else cls.ram\n\n    @classmethod\n    def resolvedSize(cls, node):\n        \"\"\" Return the resolved size for the given node instance.\n\n        If `size` is a callable, it is called with the node instance as parameter.\n        If `size` is an integer, it is returned as-is.\n        Objects with a `computeSize` method are supported for backward compatibility.\n        \"\"\"\n        if callable(cls.size):\n            return cls.size(node)\n        if isinstance(cls.size, int):\n            return cls.size\n        # Backward compatibility with external size classes using computeSize instead of __call__\n        if hasattr(cls.size, 'computeSize'):\n            logging.warning(f\"The plugin '{node.nodeType}' should use a callable instead of the deprecated method 'computeSize'.\")\n            return cls.size.computeSize(node)\n        raise ValueError(f\"{node.name} size attribute is invalid\")\n\n    def upgradeAttributeValues(self, attrValues, fromVersion):\n        return attrValues\n\n    @classmethod\n    def onNodeCreated(cls, node):\n        \"\"\"\n        Called after a node instance created from this node descriptor has been added to a Graph.\n        \"\"\"\n        pass\n\n    @classmethod\n    def update(cls, node):\n        \"\"\" Method call before node's internal update on invalidation.\n\n        Args:\n            node: the BaseNode instance being updated\n        See Also:\n            BaseNode.updateInternals\n        \"\"\"\n        pass\n\n    @classmethod\n    def postUpdate(cls, node):\n        \"\"\" Method call after node's internal update on invalidation.\n\n        Args:\n            node: the BaseNode instance being updated\n        See Also:\n            NodeBase.updateInternals\n        \"\"\"\n        pass\n\n    def preprocess(self, node):\n        \"\"\" Gets invoked just before the processChunk method for the node.\n\n        Args:\n            node: The BaseNode instance about to be processed.\n        \"\"\"\n        pass\n\n    def postprocess(self, node):\n        \"\"\" Gets invoked after the processChunk method for the node.\n\n        Args:\n            node: The BaseNode instance which is processed.\n        \"\"\"\n        pass\n\n    def process(self, node):\n        raise NotImplementedError(f'No process implementation on node: \"{node.name}\"')\n\n    def processChunk(self, chunk):\n        if self.parallelization is None:\n            self.process(chunk.node)\n        else:\n            raise NotImplementedError(f'No process implementation on node: \"{chunk.node.name}\"')\n\n    def executeChunkCommandLine(self, chunk, cmd, env=None):\n        try:\n            with open(chunk.getLogFile(), 'a') as logF:\n                chunk.status.commandLine = cmd\n                chunk.saveStatusFile()\n                cmdList = shlex.split(cmd)\n                # Resolve executable to full path\n                prog = shutil.which(cmdList[0], path=env.get(\"PATH\") if env else None)\n\n                print(f\"Starting Process for '{chunk.node.name}'\")\n                print(f\" - commandLine: {cmd}\")\n                print(f\" - logFile: {chunk.getLogFile()}\")\n                if prog:\n                    cmdList[0] = Path(prog).as_posix()\n                    print(f\" - command full path: {cmdList[0]}\")\n\n                # Change the process group to avoid Meshroom main process being killed if the\n                # subprocess gets terminated by the user or an Out Of Memory (OOM kill).\n                if sys.platform == \"win32\":\n                    from subprocess import CREATE_NEW_PROCESS_GROUP\n                    platformArgs = {\"creationflags\": CREATE_NEW_PROCESS_GROUP}\n                    # Note: DETACHED_PROCESS means fully detached process.\n                    # We do not want a fully detached process to ensure that if Meshroom is killed,\n                    # the subprocesses are killed too.\n                else:\n                    platformArgs = {\"start_new_session\": True}\n                    # Note: \"preexec_fn\"=os.setsid is the old way before python-3.2\n\n                chunk.subprocess = psutil.Popen(\n                    cmdList,\n                    stdout=logF,\n                    stderr=logF,\n                    cwd=chunk.node.internalFolder,\n                    env=env,\n                    text=True,\n                    **platformArgs,\n                )\n                exitCleanup.addSubprocess(chunk.subprocess)\n\n                if hasattr(chunk, \"statThread\"):\n                    # We only have a statThread if the node is running in the current process\n                    # and not in a dedicated environment/process.\n                    chunk.statThread.proc = chunk.subprocess\n\n                stdout, stderr = chunk.subprocess.communicate()\n\n                chunk.status.returnCode = chunk.subprocess.returncode\n\n                if chunk.subprocess.returncode and chunk.subprocess.returncode < 0:\n                    signal_num = -chunk.subprocess.returncode\n                    logF.write(f\"Process was killed by signal: {signal_num}\")\n                    try:\n                        status = chunk.subprocess.status()\n                        logF.write(f\"Process status: {status}\")\n                    except Exception:\n                        pass\n\n            if chunk.subprocess.returncode != 0:\n                with open(chunk.getLogFile(), \"r\") as logF:\n                    logContent = \"\".join(logF.readlines())\n                raise RuntimeError(f'Error on node \"{chunk.name}\":\\nLog:\\n{logContent}')\n        finally:\n            chunk.subprocess = None\n\n    def stopProcess(self, chunk):\n        # The same node could exists several times in the graph and\n        # only one would have the running subprocess; ignore all others\n        if not chunk.subprocess:\n            logging.warning(f\"[{chunk.node.name}] stopProcess: no subprocess\")\n            return\n\n        # Retrieve process tree\n        processes = chunk.subprocess.children(recursive=True) + [chunk.subprocess]\n        logging.debug(f\"[{chunk.node.name}] Processes to stop: {len(processes)}\")\n        for process in processes:\n            try:\n                # With terminate, the process has a chance to handle cleanup\n                process.terminate()\n            except psutil.NoSuchProcess:\n                pass\n\n        # If it is still running, force kill it\n        for process in processes:\n            try:\n                # Use is_running() instead of poll() as we use a psutil.Process object\n                if process.is_running():  # Check if process is still alive\n                    process.kill()  # Forcefully kill it\n            except psutil.NoSuchProcess:\n                logging.info(f\"[{chunk.node.name}] Process already terminated.\")\n            except psutil.AccessDenied:\n                logging.info(f\"[{chunk.node.name}] Permission denied to kill the process.\")\n\n\nclass InputNode(BaseNode):\n    \"\"\"\n    Node that does not need to be processed, it is just a placeholder for inputs.\n    \"\"\"\n    _mrNodeType: MrNodeType = MrNodeType.INPUT\n    internalInputs = InternalAttributesFactory.getInternalAttributes(_mrNodeType)\n\n    def __init__(self):\n        super(InputNode, self).__init__()\n\n    def getMrNodeType(self):\n        return self._mrNodeType\n\n    def processChunk(self, chunk):\n        pass\n\n    def process(self, node):\n        pass\n\nclass BackdropNode(BaseNode):\n    \"\"\"\n    Node that does not need to be processed, it is just a placeholder for grouping other nodes.\n    \"\"\"\n    _mrNodeType: MrNodeType = MrNodeType.BACKDROP\n    internalInputs = InternalAttributesFactory.getInternalAttributes(_mrNodeType)\n\n    def __init__(self):\n        super(BackdropNode, self).__init__()\n\n    def getMrNodeType(self):\n        return self._mrNodeType\n\n    def processChunk(self, chunk):\n        pass\n\n    def process(self, node):\n        pass\n\n\nclass Node(BaseNode):\n    pythonExecutable = \"python\"\n    _mrNodeType: MrNodeType = MrNodeType.NODE\n\n    def __init__(self):\n        super(Node, self).__init__()\n\n    def getMrNodeType(self):\n        return self._mrNodeType\n\n    def processChunkInEnvironment(self, chunk):\n        meshroomComputeCmd = f\"{chunk.node.nodeDesc.pythonExecutable} {_MESHROOM_COMPUTE}\" + \\\n                             f\" \\\"{chunk.node.graph.filepath}\\\" --node {chunk.node.name}\" + \\\n                              \" --extern --inCurrentEnv\"\n\n        if len(chunk.node.getChunks()) > 1:\n            meshroomComputeCmd += f\" --iteration {chunk.range.iteration}\"\n\n        runtimeEnv = chunk.node.nodeDesc.plugin.runtimeEnv\n        cmdPrefix = chunk.node.nodeDesc.plugin.commandPrefix\n        cmdSuffix = chunk.node.nodeDesc.plugin.commandSuffix\n        self.executeChunkCommandLine(chunk, cmdPrefix + meshroomComputeCmd + cmdSuffix,\n                                     env=runtimeEnv)\n\n\nclass CommandLineNode(BaseNode):\n    \"\"\"\n    \"\"\"\n    commandLine = \"\"  # need to be defined on the node\n    parallelization = None\n    commandLineRange = \"\"\n    _mrNodeType: MrNodeType = MrNodeType.COMMANDLINE\n\n    def __init__(self):\n        super(CommandLineNode, self).__init__()\n\n    def getMrNodeType(self):\n        return self._mrNodeType\n\n    def buildCommandLine(self, chunk) -> str:\n        cmdLineVars = chunk.node.createCmdLineVars()\n        cmdPrefix = \"\"\n        cmdSuffix = \"\"\n        if chunk.node.nodeDesc.plugin:\n            cmdPrefix = chunk.node.nodeDesc.plugin.commandPrefix\n            cmdSuffix = chunk.node.nodeDesc.plugin.commandSuffix\n        if chunk.node.isParallelized and chunk.node.size > 1:\n            cmdSuffix = \" \" + self.commandLineRange.format(**chunk.range.toDict()) + \" \" + cmdSuffix\n\n        # In the case of a lambda, we want a single \"node\" argument and not the node descriptor \"self\".\n        # Therefore, we use getattr_static to retrieve the raw lambda instead of a bound method, which\n        # would impose \"self\" as the first argument if we accessed \"self.commandLine\".\n        commandLineValue = getattr_static(self, 'commandLine')\n        if callable(commandLineValue):\n            cmd = commandLineValue(chunk.node)\n        else:\n            cmd = commandLineValue.format(**chunk.node._expVars, **chunk.node._staticExpVars, **cmdLineVars)\n        return cmdPrefix + cmd + cmdSuffix\n\n    def processChunk(self, chunk):\n        cmd = self.buildCommandLine(chunk)\n        runtimeEnv = chunk.node.nodeDesc.plugin.runtimeEnv\n        self.executeChunkCommandLine(chunk, cmd, env=runtimeEnv)\n\n\n# Specific command line node for AliceVision apps\nclass AVCommandLineNode(CommandLineNode):\n\n    cgroupParsed = False\n    cmdMem = \"\"\n    cmdCore = \"\"\n\n    def __init__(self):\n        super(AVCommandLineNode, self).__init__()\n\n        if AVCommandLineNode.cgroupParsed is False:\n\n            AVCommandLineNode.cmdMem = \"\"\n            memSize = cgroup.getCgroupMemorySize()\n            if memSize > 0:\n                AVCommandLineNode.cmdMem = f\" --maxMemory={memSize}\"\n\n            AVCommandLineNode.cmdCore = \"\"\n            coresCount = cgroup.getCgroupCpuCount()\n            if coresCount > 0:\n                AVCommandLineNode.cmdCore = f\" --maxCores={coresCount}\"\n\n            AVCommandLineNode.cgroupParsed = True\n\n    def buildCommandLine(self, chunk) -> str:\n        commandLineString = super(AVCommandLineNode, self).buildCommandLine(chunk)\n\n        return commandLineString + AVCommandLineNode.cmdMem + AVCommandLineNode.cmdCore\n\n\nclass InitNode(object):\n    def __init__(self):\n        super(InitNode, self).__init__()\n\n    def initialize(self, node, inputs, recursiveInputs):\n        \"\"\"\n        Initialize the attributes that are needed for a node to start running.\n\n        Args:\n            node (Node): the node whose attributes must be initialized\n            inputs (list): the user-provided list of input files/directories\n            recursiveInputs (list): the user-provided list of input directories to search\n                                    recursively for images\n        \"\"\"\n        pass\n\n    def resetAttributes(self, node, attributeNames):\n        \"\"\"\n        Reset the values of the provided attributes for a node.\n\n        Args:\n            node (Node): the node whose attributes are to be reset\n            attributeNames (list): the list containing the names of the attributes to reset\n        \"\"\"\n        for attrName in attributeNames:\n            if node.hasAttribute(attrName):\n                node.attribute(attrName).resetToDefaultValue()\n\n    def extendAttributes(self, node, attributesDict):\n        \"\"\"\n        Extend the values of the provided attributes for a node.\n\n        Args:\n            node (Node): the node whose attributes are to be extended\n            attributesDict (dict): the dictionary containing the attributes' names (as keys) and the\n                                   values to extend with\n        \"\"\"\n        for attr in attributesDict.keys():\n            if node.hasAttribute(attr):\n                node.attribute(attr).extend(attributesDict[attr])\n\n    def setAttributes(self, node, attributesDict):\n        \"\"\"\n        Set the values of the provided attributes for a node.\n\n        Args:\n            node (Node): the node whose attributes are to be extended\n            attributesDict (dict): the dictionary containing the attributes' names (as keys) and the\n                                   values to set\n        \"\"\"\n        for attr in attributesDict:\n            if node.hasAttribute(attr):\n                node.attribute(attr).value = attributesDict[attr]\n"
  },
  {
    "path": "meshroom/core/desc/shapeAttribute.py",
    "content": "from meshroom.core.desc import ListAttribute, GroupAttribute, StringParam, FloatParam, Geometry, Size2d, Vec2d\n\nclass Shape(GroupAttribute):\n    \"\"\"\n    Base attribute for all Shape attribute.\n    Countains several attributes (inherit from GroupAttribute).\n    \"\"\"\n    def __init__(self, geometryItems, name, label, description, commandLineGroup=\"allParams\", advanced=False, semantic=\"\",\n                 enabled=True, visible=True, exposed=False):\n        # Shape group desciption\n        items = [\n            StringParam(name=\"userName\", label=\"User Name\", description=\"User shape name.\", value=\"\",\n                        commandLineGroup=commandLineGroup, advanced=advanced, enabled=enabled, visible=visible, exposed=exposed),\n            StringParam(name=\"userColor\", label=\"User Color\", description=\"User shape color.\", value=\"#2a82da\",\n                        commandLineGroup=commandLineGroup, advanced=advanced, enabled=enabled, visible=visible, exposed=exposed),\n            Geometry(geometryItems, name=\"geometry\", label=\"Geometry\", description=\"Shape geometry.\",\n                     commandLineGroup=commandLineGroup, advanced=advanced, enabled=enabled, visible=visible, exposed=exposed)\n        ]\n        # GroupAttribute constructor\n        super(Shape, self).__init__(items=items, name=name, label=label, description=description,\n                                    commandLineGroup=commandLineGroup, advanced=advanced, semantic=semantic,\n                                    enabled=enabled, visible=visible, exposed=exposed)\n\n    def getInstanceType(self):\n        \"\"\"\n        Return the correct Attribute instance corresponding to the description.\n        \"\"\"\n        # Import within the method to prevent cyclic dependencies\n        from meshroom.core.attribute import ShapeAttribute\n        return ShapeAttribute\n\nclass ShapeList(ListAttribute):\n    \"\"\"\n    List attribute of Shape attribute.\n    Countains several attributes (inherit from ListAttribute).\n    \"\"\"\n    def __init__(self, shape: Shape, name, label, description, commandLineGroup=\"allParams\", advanced=False, semantic=\"\",\n                 enabled=True, visible=True, exposed=False):\n        # ListAttribute constructor\n        super(ShapeList, self).__init__(elementDesc=shape, name=name, label=label, description=description,\n                                        commandLineGroup=commandLineGroup, advanced=advanced, semantic=semantic,\n                                        enabled=enabled, visible=visible, exposed=exposed)\n\n    def getInstanceType(self):\n        \"\"\"\n        Return the correct Attribute instance corresponding to the description.\n        \"\"\"\n        # Import within the method to prevent cyclic dependencies\n        from meshroom.core.attribute import ShapeListAttribute\n        return ShapeListAttribute\n\nclass Point2d(Shape):\n    \"\"\"\n    Point2d is a Shape attribute that allows to display and modify a 2d point.\n    \"\"\"\n    def __init__(self, name, label, description, keyable=False, keyType=None,\n                 commandLineGroup=\"allParams\", advanced=False, semantic=\"\",\n                 enabled=True, visible=True, exposed=False):\n        # Geometry group desciption\n        geometryItems = [\n            FloatParam(name=\"x\", label=\"X\", description=\"X coordinate.\", value=-1.0, keyable=keyable, keyType=keyType,\n                       commandLineGroup=commandLineGroup, advanced=advanced, enabled=enabled, visible=visible, exposed=exposed),\n            FloatParam(name=\"y\", label=\"Y\", description=\"Y coordinate.\", value=-1.0, keyable=keyable, keyType=keyType,\n                       commandLineGroup=commandLineGroup, advanced=advanced, enabled=enabled, visible=visible, exposed=exposed)\n        ]\n        # ShapeAttribute constructor\n        super(Point2d, self).__init__(geometryItems, name, label, description, commandLineGroup=None, advanced=advanced,\n                                      semantic=semantic, enabled=enabled, visible=visible, exposed=exposed)\n\nclass Line2d(Shape):\n    \"\"\"\n    Line2d is a Shape attribute that allows to display and modify a 2d line.\n    \"\"\"\n    def __init__(self, name, label, description, keyable=False, keyType=None,\n                 commandLineGroup=\"allParams\", advanced=False, semantic=\"\",\n                 enabled=True, visible=True, exposed=False):\n        # Geometry group desciption\n        geometryItems = [\n            Vec2d(name=\"a\", label=\"A\", description=\"Line A point.\", x=-1.0, y=-1.0, keyable=keyable, keyType=keyType,\n                  commandLineGroup=commandLineGroup, advanced=advanced, enabled=enabled, visible=visible, exposed=exposed),\n            Vec2d(name=\"b\", label=\"B\", description=\"Line B point.\", x=-1.0, y=-1.0, keyable=keyable, keyType=keyType,\n                  commandLineGroup=commandLineGroup, advanced=advanced, enabled=enabled, visible=visible, exposed=exposed)\n        ]\n        # ShapeAttribute constructor\n        super(Line2d, self).__init__(geometryItems, name, label, description, commandLineGroup=None, advanced=advanced,\n                                     semantic=semantic, enabled=enabled, visible=visible, exposed=exposed)\n\nclass Rectangle(Shape):\n    \"\"\"\n    Rectangle is a Shape attribute that allows to display and modify a rectangle.\n    \"\"\"\n    def __init__(self, name, label, description, keyable=False, keyType=None,\n                 commandLineGroup=\"allParams\", advanced=False, semantic=\"\",\n                 enabled=True, visible=True, exposed=False):\n        # Geometry group desciption\n        geometryItems = [\n            Vec2d(name=\"center\", label=\"Center\", description=\"Rectangle center.\", x=-1.0, y=-1.0,\n                  keyable=keyable, keyType=keyType, commandLineGroup=commandLineGroup, advanced=advanced,\n                  enabled=enabled, visible=visible, exposed=exposed),\n            Size2d(name=\"size\", label=\"Size\", description=\"Rectangle size.\", width=-1.0, height=-1.0,\n                   keyable=keyable, keyType=keyType, commandLineGroup=commandLineGroup, advanced=advanced,\n                   enabled=enabled, visible=visible, exposed=exposed)\n        ]\n        # ShapeAttribute constructor\n        super(Rectangle, self).__init__(geometryItems, name, label, description, commandLineGroup=None, advanced=advanced,\n                                        semantic=semantic, enabled=enabled, visible=visible, exposed=exposed)\n\nclass Circle(Shape):\n    \"\"\"\n    Circle is a Shape attribute that allows to display and modify a circle.\n    \"\"\"\n    def __init__(self, name, label, description, keyable=False, keyType=None,\n                 commandLineGroup=\"allParams\", advanced=False, semantic=\"\",\n                 enabled=True, visible=True, exposed=False):\n        # Geometry group desciption\n        geometryItems = [\n            Vec2d(name=\"center\", label=\"Center\", description=\"Circle center.\", x=-1.0, y=-1.0,\n                  keyable=keyable, keyType=keyType, commandLineGroup=commandLineGroup, advanced=advanced,\n                  enabled=enabled, visible=visible, exposed=exposed),\n            FloatParam(name=\"radius\", label=\"Radius\", description=\"Circle radius.\", value=-1.0,\n                       keyable=keyable, keyType=keyType, commandLineGroup=commandLineGroup, advanced=advanced,\n                       enabled=enabled, visible=visible, exposed=exposed)\n        ]\n        # ShapeAttribute constructor\n        super(Circle, self).__init__(geometryItems, name, label, description, commandLineGroup=None, advanced=advanced,\n                                     semantic=semantic, enabled=enabled, visible=visible, exposed=exposed)"
  },
  {
    "path": "meshroom/core/evaluation.py",
    "content": "#!/usr/bin/env python\n\nimport ast, math\n\n\nclass MathEvaluator:\n    \"\"\" Evaluate math expressions\n\n    ..code::py\n        # Example usage\n        mev = MathEvaluator()\n        print(mev.evaluate(\"e-1+cos(2*pi)\"))\n        print(mev.evaluate(\"pow(2, 8)\"))\n        print(mev.evaluate(\"round(sin(pi), 3)\"))\n    \"\"\"\n\n    # Allowed math symbols\n    allowed_symbols = {\n        \"e\": math.e, \"pi\": math.pi,\n        \"cos\": math.cos, \"sin\": math.sin, \"tan\": math.tan, \"exp\": math.exp,\n        \"pow\": pow, \"round\": round, \"abs\": abs, \"min\": min, \"max\": max,\n        \"sqrt\": math.sqrt, \"log\": math.log\n    }\n\n    # Allowed AST node types\n    allowed_nodes = (\n        ast.Expression, ast.BinOp, ast.UnaryOp, ast.Call, ast.Name, ast.Load,\n        ast.Add, ast.Sub, ast.Mult, ast.Div, ast.Pow, ast.Mod, ast.FloorDiv,\n        ast.USub, ast.UAdd, ast.BitXor, ast.BitOr, ast.BitAnd,\n        ast.LShift, ast.RShift, ast.Invert,\n        ast.Constant\n    )\n\n    def _validate_ast(self, node):\n        for child in ast.walk(node):\n            if not isinstance(child, self.allowed_nodes):\n                raise ValueError(f\"Bad expression: {ast.dump(child)}\")\n            # Check that all variable/function names are whitelisted\n            if isinstance(child, ast.Name):\n                if child.id not in self.allowed_symbols:\n                    raise ValueError(f\"Unknown symbol: {child.id}\")\n\n    def evaluate(self, expr: str):\n        if any(bad in expr for bad in ('\\n', '#')):\n            raise ValueError(f\"Invalid expression: {expr}\")\n        try:\n            node = ast.parse(expr.strip(), mode=\"eval\")\n            self._validate_ast(node)\n            return eval(compile(node, \"<expr>\", \"eval\"), {\"__builtins__\": {}}, self.allowed_symbols)\n        except Exception:\n            raise ValueError(f\"Invalid expression: {expr}\")\n"
  },
  {
    "path": "meshroom/core/exception.py",
    "content": "#!/usr/bin/env python\n\n\nclass MeshroomException(Exception):\n    \"\"\" Base class for Meshroom exceptions \"\"\"\n    pass\n\n\nclass GraphException(MeshroomException):\n    \"\"\" Base class for Graph exceptions \"\"\"\n    pass\n\n\nclass InvalidEdgeError(GraphException):\n    \"\"\" Raised when an edge between two attributes cannot be created. \"\"\"\n    def __init__(self, srcAttrName: str, dstAttrName: str, msg: str) -> None:\n        super().__init__(f\"Failed to connect {srcAttrName}->{dstAttrName}: {msg}\")\n\n\nclass GraphCompatibilityError(GraphException):\n    \"\"\"\n    Raised when node compatibility issues occur when loading a graph.\n\n    Args:\n        filepath: The path to the file that caused the error.\n        issues: A dictionnary of node names and their respective compatibility issues.\n    \"\"\"\n    def __init__(self, filepath, issues: dict[str, str]) -> None:\n        self.filepath = filepath\n        self.issues = issues\n        msg = f\"Compatibility issues found when loading {self.filepath}: {self.issues}\"\n        super().__init__(msg)\n\n\nclass UnknownNodeTypeError(GraphException):\n    \"\"\"\n    Raised when asked to create a unknown node type.\n    \"\"\"\n    def __init__(self, nodeType, msg=None):\n        msg = \"Unknown Node Type: \" + nodeType\n        super().__init__(msg)\n        self.nodeType = nodeType\n\n\nclass NodeUpgradeError(GraphException):\n    def __init__(self, nodeName, details=None):\n        msg = f\"Failed to upgrade node {nodeName}\"\n        if details:\n            msg += f\": {details}\"\n        super().__init__(msg)\n\n\nclass GraphVisitMessage(GraphException):\n    \"\"\" Base class for sending messages via exceptions during a graph visit. \"\"\"\n    pass\n\n\nclass StopGraphVisit(GraphVisitMessage):\n    \"\"\" Immediately interrupt graph visit. \"\"\"\n    pass\n\n\nclass StopBranchVisit(GraphVisitMessage):\n    \"\"\" Immediately stop branch visit. \"\"\"\n    pass\n\n\nclass CyclicDependencyError(GraphVisitMessage):\n    \"\"\" Do not start visiting the graph. \"\"\"\n    pass\n"
  },
  {
    "path": "meshroom/core/fileUtils.py",
    "content": "import os\nimport re\n\npattern = r\"(?P<FILESTEM_PREFIX>.*?)(?P<FRAMEID_STR>[-._]\\d+)?(?P<EXTENSION>\\.\\w{3,4})\"\ncompiled_pattern = re.compile(pattern)\ncompiled_frameId = re.compile(r\"(\\D+)?(?P<FRAMEID>\\d+$)\")\n\ndef getFileElements(inputFilePath: str):\n\n    filename = os.path.basename(inputFilePath)\n    match = compiled_pattern.fullmatch(filename)\n    frameId_str = match.group(\"FRAMEID_STR\")\n\n    fileElements = {}\n    if match:\n        fileElements = {\n            \"<PATH>\": inputFilePath,\n            \"<FILENAME>\": filename,\n            \"<FILESTEM>\": match.group(\"FILESTEM_PREFIX\"),\n            \"<FILESTEM_PREFIX>\": match.group(\"FILESTEM_PREFIX\"),\n            \"<EXTENSION>\": match.group(\"EXTENSION\"),\n        }\n    if frameId_str is not None:\n        fileElements[\"<FRAMEID_STR>\"] = frameId_str\n        fileElements[\"<FILESTEM>\"] += frameId_str\n        match_frameId = compiled_frameId.search(frameId_str)\n        fileElements[\"<FRAMEID>\"] = match_frameId.group(\"FRAMEID\")\n\n    return fileElements\n\n\ndef getViewElements(vp):\n\n    vpPath = vp.childAttribute(\"path\").value\n\n    viewElements = getFileElements(vpPath)\n\n    viewElements[\"<VIEW_ID>\"] = str(vp.childAttribute(\"viewId\").value)\n    viewElements[\"<INTRINSIC_ID>\"] = str(vp.childAttribute(\"intrinsicId\").value)\n    viewElements[\"<POSE_ID>\"] = str(vp.childAttribute(\"poseId\").value)\n\n    return viewElements\n\n\ndef replacePatterns(input, pattern, replacements):\n    # Use all substrings of \"input\" matching the regex \"pattern\" as a key to substitute themselves by their value in the dictionary \"replacements\".\n    # If \"replacements\" does not contain the key, the key is removed from \"input\" to build the resolved string.\n    def replaceMatch(match):\n        key = match.group()\n        return replacements.get(key, \"\")\n    return pattern.sub(replaceMatch, input)\n\n\ncompiled_element = re.compile(r\"<\\w*>\")\n\ndef resolvePath(input, outputTemplate: str) -> str:\n\n    if isinstance(input, str):\n        replacements = getFileElements(input)\n    else:\n        replacements = getViewElements(input)\n\n    resolved = replacePatterns(outputTemplate, compiled_element, replacements)\n\n    return resolved\n"
  },
  {
    "path": "meshroom/core/graph.py",
    "content": "import json\nimport logging\nimport os\nimport re\nfrom typing import Any, Optional\nfrom collections.abc import Iterable\nfrom collections import defaultdict, OrderedDict\nimport weakref\nfrom contextlib import contextmanager\nfrom pathlib import Path\n\nfrom enum import Enum\n\nimport meshroom\nimport meshroom.core\nfrom meshroom.common import BaseObject, DictModel, Slot, Signal, Property\nfrom meshroom.core import Version\nfrom meshroom.core import submitters\nfrom meshroom.core.attribute import Attribute, ListAttribute, GroupAttribute\nfrom meshroom.core.exception import GraphCompatibilityError, InvalidEdgeError, StopGraphVisit, StopBranchVisit, CyclicDependencyError\nfrom meshroom.core.graphIO import GraphIO, GraphSerializer, TemplateGraphSerializer, PartialGraphSerializer\nfrom meshroom.core.node import BaseNode, Status, Node, CompatibilityNode\nfrom meshroom.core.nodeFactory import nodeFactory, getNodeConstructor\nfrom meshroom.core.mtyping import PathLike\nfrom meshroom.core.submitter import BaseSubmittedJob, jobManager\n\n# Replace default encoder to support Enums\n\nDefaultJSONEncoder = json.JSONEncoder  # store the original one\n\n\nclass MyJSONEncoder(DefaultJSONEncoder):  # declare a new one with Enum support\n    def default(self, obj):\n        if isinstance(obj, Enum):\n            return obj.name\n        return DefaultJSONEncoder.default(self, obj)  # use the default one for all other types\n\n\njson.JSONEncoder = MyJSONEncoder  # replace the default implementation with our new one\n\n\n@contextmanager\ndef GraphModification(graph):\n    \"\"\"\n    A Context Manager that can be used to trigger only one Graph update\n    for a group of several modifications.\n    GraphModifications can be nested.\n    \"\"\"\n    if not isinstance(graph, Graph):\n        raise ValueError(\"GraphModification expects a Graph instance\")\n    # Store update policy for nested usage\n    enabled = graph.updateEnabled\n    # Disable graph update for nested block\n    # (does nothing if already disabled)\n    graph.updateEnabled = False\n    try:\n        yield  # Execute nested block\n    except Exception:\n        raise\n    finally:\n        # Restore update policy\n        graph.updateEnabled = enabled\n\n\nclass Edge(BaseObject):\n\n    def __init__(self, src, dst, parent=None):\n        super().__init__(parent)\n        self._src = weakref.ref(src)\n        self._dst = weakref.ref(dst)\n        self._repr = f\"<Edge> {self._src()} -> {self._dst()}\"\n\n    @property\n    def src(self):\n        return self._src()\n\n    @property\n    def dst(self):\n        return self._dst()\n\n    src = Property(Attribute, src.fget, constant=True)\n    dst = Property(Attribute, dst.fget, constant=True)\n\n\nWHITE = 0\nGRAY = 1\nBLACK = 2\n\n\nclass Visitor:\n    \"\"\"\n    Base class for Graph Visitors that does nothing.\n    Sub-classes can override any method to implement specific algorithms.\n    \"\"\"\n    def __init__(self, reverse, dependenciesOnly):\n        super().__init__()\n        self.reverse = reverse\n        self.dependenciesOnly = dependenciesOnly\n\n    # def initializeVertex(self, s, g):\n    #     '''is invoked on every vertex of the graph before the start of the graph search.'''\n    #     pass\n    # def startVertex(self, s, g):\n    #     '''is invoked on the source vertex once before the start of the search.'''\n    #     pass\n\n    def discoverVertex(self, u, g):\n        \"\"\" Is invoked when a vertex is encountered for the first time. \"\"\"\n        pass\n\n    def examineEdge(self, e, g):\n        \"\"\" Is invoked on every out-edge of each vertex after it is discovered.\"\"\"\n        pass\n\n    def treeEdge(self, e, g):\n        \"\"\"\n        Is invoked on each edge as it becomes a member of the edges that form the search tree.\n        If you wish to record predecessors, do so at this event point.\n        \"\"\"\n        pass\n\n    def backEdge(self, e, g):\n        \"\"\" Is invoked on the back edges in the graph. \"\"\"\n        pass\n\n    def forwardOrCrossEdge(self, e, g):\n        \"\"\"\n        Is invoked on forward or cross edges in the graph.\n        In an undirected graph this method is never called.\n        \"\"\"\n        pass\n\n    def finishEdge(self, e, g):\n        \"\"\"\n        Is invoked on the non-tree edges in the graph\n        as well as on each tree edge after its target vertex is finished.\n        \"\"\"\n        pass\n\n    def finishVertex(self, u, g):\n        \"\"\"\n        Is invoked on a vertex after all of its out edges have been added to the search tree and\n        all of the adjacent vertices have been discovered (but before their out-edges have been\n        examined).\n        \"\"\"\n        pass\n\n\ndef changeTopology(func):\n    \"\"\"\n    Graph methods modifying the graph topology (add/remove edges or nodes)\n    must be decorated with 'changeTopology' for update mechanism to work as intended.\n    \"\"\"\n    def decorator(self, *args, **kwargs):\n        assert isinstance(self, Graph)\n        # call method\n        result = func(self, *args, **kwargs)\n        # mark graph dirty\n        self.dirtyTopology = True\n        # request graph update\n        self.update()\n        return result\n    return decorator\n\n\ndef blockNodeCallbacks(func):\n    \"\"\"\n    Graph methods loading serialized graph content must be decorated with 'blockNodeCallbacks',\n    to avoid attribute changed callbacks defined on node descriptions to be triggered during\n    this process.\n    \"\"\"\n    def inner(self, *args, **kwargs):\n        self._loading = True\n        try:\n            return func(self, *args, **kwargs)\n        finally:\n            self._loading = False\n    return inner\n\n\ndef generateTempProjectFilepath(tmpFolder=None):\n    \"\"\"\n    Generate a temporary project filepath.\n    This method is used to generate a temporary project file for the current graph.\n    \"\"\"\n    from datetime import datetime\n    if tmpFolder is None:\n        from meshroom.env import EnvVar\n        tmpFolder = EnvVar.get(EnvVar.MESHROOM_TEMP_PATH)\n    timestamp = datetime.now().strftime(\"%Y-%m-%d_%H-%M\")\n    return os.path.join(tmpFolder, f\"meshroom_{timestamp}.mg\")\n\n\nclass Graph(BaseObject):\n    \"\"\"\n    _________________      _________________      _________________\n    |               |      |               |      |               |\n    |     Node A    |      |     Node B    |      |     Node C    |\n    |               | edge |               | edge |               |\n    |input    output|>---->|input    output|>---->|input    output|\n    |_______________|      |_______________|      |_______________|\n\n    Data structures:\n\n        nodes = {'A': <nodeA>, 'B': <nodeB>, 'C': <nodeC>}\n        edges = {B.input: A.output, C.input: B.output,}\n\n    \"\"\"\n\n    def __init__(self, name: str = \"\", parent: BaseObject = None):\n        super().__init__(parent)\n        self.name: str = name\n        self._loading: bool = False\n        self._saving: bool = False\n        self._updateEnabled: bool = True\n        self._updateRequested: bool = False\n        self.dirtyTopology: bool = False\n        self._nodesMinMaxDepths = {}\n        self._computationBlocked = {}\n        self._canComputeLeaves: bool = True\n        self._nodes = DictModel(keyAttrName='name', parent=self)\n        # Edges: use dst attribute as unique key since it can only have one input connection\n        self._edges = DictModel(keyAttrName='dst', parent=self)\n        self._compatibilityNodes = DictModel(keyAttrName='name', parent=self)\n        self._cacheDir: str = ''\n        self._filepath: str = ''\n        self._fileDateVersion = 0\n        self.header = {}\n\n    def clear(self):\n        self._clearGraphContent()\n        self.header.clear()\n        self._unsetFilepath()\n\n    def _clearGraphContent(self):\n        self._edges.clear()\n        # Tell QML nodes are going to be deleted\n        for node in self._nodes:\n            node.alive = False\n        self._nodes.clear()\n        self._compatibilityNodes.clear()\n\n    @property\n    def fileFeatures(self):\n        \"\"\" Get loaded file supported features based on its version. \"\"\"\n        return GraphIO.getFeaturesForVersion(self.header.get(GraphIO.Keys.FileVersion, \"0.0\"))\n\n    @property\n    def isLoading(self):\n        \"\"\" Return True if the graph is currently being loaded. \"\"\"\n        return self._loading\n\n    @property\n    def isSaving(self):\n        \"\"\" Return True if the graph is currently being saved. \"\"\"\n        return self._saving\n\n    @Slot(str)\n    def load(self, filepath: PathLike):\n        \"\"\"\n        Load a Meshroom Graph \".mg\" file in place.\n\n        Args:\n            filepath: The path to the Meshroom Graph file to load.\n        \"\"\"\n        self._setFilepath(filepath)\n        self._deserialize(Graph._loadGraphData(filepath))\n        self._fileDateVersion = os.path.getmtime(filepath)\n\n    def initFromTemplate(self, filepath: PathLike, copyOutputs: bool = False):\n        \"\"\"\n        Deserialize a template Meshroom Graph \".mg\" file in place.\n\n        When initializing from a template, the internal filepath of the graph instance is not set.\n        Saving the file on disk will require to specify a filepath.\n\n        Args:\n            filepath: The path to the Meshroom Graph file to load.\n            copyOutputs: (optional) Whether to keep 'CopyFiles' nodes.\n        \"\"\"\n        self._deserialize(Graph._loadGraphData(filepath))\n\n        # Creating nodes from a template is conceptually similar to explicit node creation,\n        # therefore the nodes descriptors' \"onNodeCreated\" callback is triggered for each\n        # node instance created by this process.\n        self._triggerNodeCreatedCallback(self.nodes)\n\n        if not copyOutputs:\n            with GraphModification(self):\n                for node in [node for node in self.nodes if node.nodeType == \"CopyFiles\"]:\n                    self.removeNode(node.name)\n\n    @staticmethod\n    def _loadGraphData(filepath: PathLike) -> dict:\n        \"\"\"Deserialize the content of the Meshroom Graph file at `filepath` to a dictionnary.\"\"\"\n        with open(filepath) as file:\n            graphData = json.load(file)\n        return graphData\n\n    @blockNodeCallbacks\n    def _deserialize(self, graphData: dict):\n        \"\"\"Deserialize `graphData` in the current Graph instance.\n\n        Args:\n            graphData: The serialized Graph.\n        \"\"\"\n        self._clearGraphContent()\n        self.header.clear()\n\n        self.header = graphData.get(GraphIO.Keys.Header, {})\n        fileVersion = Version(self.header.get(GraphIO.Keys.FileVersion, \"0.0\"))\n        graphContent = self._normalizeGraphContent(graphData, fileVersion)\n        isTemplate = self.header.get(GraphIO.Keys.Template, False)\n\n        with GraphModification(self):\n            # iterate over nodes sorted by suffix index in their names\n            for nodeName, nodeData in sorted(\n                graphContent.items(), key=lambda x: self.getNodeIndexFromName(x[0])\n            ):\n                self._deserializeNode(nodeData, nodeName, self)\n\n            # Create graph edges by resolving attributes expressions\n            self._applyExpr()\n            \n        # Templates are specific: they contain only the minimal amount of \n        # serialized data to describe the graph structure.\n        # They are not meant to be computed: therefore, we can early return here,\n        # as uid conflict evaluation is only meaningful for nodes with computed data.\n        if isTemplate:\n            return\n\n        # By this point, the graph has been fully loaded and an updateInternals has been triggered, so all the\n        # nodes' links have been resolved and their UID computations are all complete.\n        # It is now possible to check whether the UIDs stored in the graph file for each node correspond to the ones\n        # that were computed.\n        self._evaluateUidConflicts(graphContent)\n\n    def _normalizeGraphContent(self, graphData: dict, fileVersion: Version) -> dict:\n        graphContent = graphData.get(GraphIO.Keys.Graph, graphData)\n\n        if fileVersion < Version(\"2.0\"):\n            # For internal folders, all \"{uid0}\" keys should be replaced with \"{uid}\"\n            updatedFileData = json.dumps(graphContent).replace(\"{uid0}\", \"{uid}\")\n\n            # For fileVersion < 2.0, the nodes' UID is stored as:\n            # \"uids\": {\"0\": \"hashvalue\"}\n            # These should be identified and replaced with:\n            # \"uid\": \"hashvalue\"\n            uidPattern = re.compile(r'\"uids\": \\{\"0\":.*?\\}')\n            uidOccurrences = uidPattern.findall(updatedFileData)\n            for occ in uidOccurrences:\n                uid = occ.split(\"\\\"\")[-2]  # UID is second to last element\n                newUidStr = fr'\"uid\": \"{uid}\"'\n                updatedFileData = updatedFileData.replace(occ, newUidStr)\n            graphContent = json.loads(updatedFileData)\n\n        return graphContent\n\n    def _deserializeNode(self, nodeData: dict, nodeName: str, fromGraph: \"Graph\"):\n        # Retrieve version info from:\n        #   1. nodeData: node saved from a CompatibilityNode\n        #   2. nodesVersion in file header: node saved from a Node\n        # If unvailable, the \"version\" field will not be set in `nodeData`.\n        if \"version\" not in nodeData:\n            if version := fromGraph._getNodeTypeVersionFromHeader(nodeData[\"nodeType\"]):\n                nodeData[\"version\"] = version\n        inTemplate = fromGraph.header.get(GraphIO.Keys.Template, False)\n        node = nodeFactory(nodeData, nodeName, inTemplate=inTemplate)\n        self._addNode(node, nodeName)\n        return node\n\n    def _getNodeTypeVersionFromHeader(self, nodeType: str, default: Optional[str] = None) -> Optional[str]:\n        nodeVersions = self.header.get(GraphIO.Keys.NodesVersions, {})\n        return nodeVersions.get(nodeType, default)\n\n    def _evaluateUidConflicts(self, graphContent: dict):\n        \"\"\"\n        Compare the computed UIDs of all the nodes in the graph with the UIDs serialized in `graphContent`. If there\n        are mismatches, the nodes with the unexpected UID are replaced with \"UidConflict\" compatibility nodes.\n  \n        Args:\n            graphContent: The serialized Graph content.\n        \"\"\"\n\n        def _serializedNodeUidMatchesComputedUid(nodeData: dict, node: BaseNode) -> bool:\n            \"\"\"\n            Returns whether the serialized UID matches the one computed in the `node` instance.\n            \"\"\"\n            if isinstance(node, CompatibilityNode):\n                return True\n            serializedUid = nodeData.get(\"uid\", None)\n            computedUid = node._uid\n            return serializedUid is None or computedUid is None or serializedUid == computedUid\n\n        uidConflictingNodes = [\n            node\n            for node in self.nodes\n            if not _serializedNodeUidMatchesComputedUid(graphContent[node.name], node)\n        ]\n\n        if not uidConflictingNodes:\n            return\n\n        logging.warning(\"UID Compatibility issues found: recreating conflicting nodes as CompatibilityNodes.\")\n\n        # A uid conflict is contagious: if a node has a uid conflict, all of its downstream nodes may be \n        # impacted as well, as the uid flows through connections.\n        # Therefore, we deal with conflicting uid nodes by depth: replacing a node with a CompatibilityNode restores\n        # the serialized uid, which might solve \"false-positives\" downstream conflicts as well.\n        nodesSortedByDepth = sorted(uidConflictingNodes, key=lambda node: node.minDepth)\n        for node in nodesSortedByDepth:\n            nodeData = graphContent[node.name]\n            # Evaluate if the node uid is still conflicting at this point, or if it has been resolved by an\n            # upstream node replacement.\n            if _serializedNodeUidMatchesComputedUid(nodeData, node):\n                continue\n            expectedUid = node._uid\n            compatibilityNode = nodeFactory(graphContent[node.name], node.name, expectedUid=expectedUid)\n            # This operation will trigger a graph update that will recompute the uids of all nodes,\n            # allowing the iterative resolution of uid conflicts.\n            self.replaceNode(node.name, compatibilityNode)\n\n\n    def importGraphContentFromFile(self, filepath: PathLike) -> list[Node]:\n        \"\"\"Import the content (nodes and edges) of another Graph file into this Graph instance.\n\n        Args:\n            filepath: The path to the Graph file to import.\n\n        Returns:\n            The list of newly created Nodes.\n        \"\"\"\n        graph = loadGraph(filepath)\n        return self.importGraphContent(graph)\n\n    @blockNodeCallbacks\n    def importGraphContent(self, graph: \"Graph\") -> list[Node]:\n        \"\"\"\n        Import the content (node and edges) of another `graph` into this Graph instance.\n\n        Nodes are imported with their original names if possible, otherwise a new unique name is generated\n        from their node type.\n\n        Args:\n            graph: The graph to import.\n\n        Returns:\n            The list of newly created Nodes.\n        \"\"\"\n\n        def _renameClashingNodes():\n            if not self.nodes:\n                return\n            unavailableNames = set(self.nodes.keys())\n            for node in graph.nodes:\n                if node._name in unavailableNames:\n                    node._name = self._createUniqueNodeName(node.nodeType, unavailableNames)\n                unavailableNames.add(node._name)\n\n        def _importNodesAndEdges() -> list[Node]:\n            importedNodes = []\n            # If we import the content of the graph within itself,\n            # iterate over a copy of the nodes as the graph is modified during the iteration.\n            nodes = graph.nodes if graph is not self else list(graph.nodes)\n            with GraphModification(self):\n                for srcNode in nodes:\n                    node = self._deserializeNode(srcNode.toDict(), srcNode.name, graph)\n                    importedNodes.append(node)\n                self._applyExpr()\n            return importedNodes\n\n        _renameClashingNodes()\n        importedNodes = _importNodesAndEdges()\n        return importedNodes\n\n    @property\n    def updateEnabled(self):\n        return self._updateEnabled\n\n    @updateEnabled.setter\n    def updateEnabled(self, enabled):\n        self._updateEnabled = enabled\n        if enabled and self._updateRequested:\n            # Trigger an update if requested while disabled\n            self.update()\n            self._updateRequested = False\n\n    @changeTopology\n    def _addNode(self, node, uniqueName):\n        \"\"\"\n        Internal method to add the given node to this Graph, with the given name (must be unique).\n        Attribute expressions are not resolved.\n        \"\"\"\n        if node.graph is not None and node.graph != self:\n            raise RuntimeError(\n                'Node \"{}\" cannot be part of the Graph \"{}\", as it is already part of the other graph \"{}\".'.format(\n                    node.nodeType, self.name, node.graph.name))\n\n        assert uniqueName not in self._nodes.keys()\n        node._name = uniqueName\n        node.graph = self\n        self._nodes.add(node)\n        node.chunksChanged.connect(self.updated)\n\n    def addNode(self, node, uniqueName=None):\n        \"\"\"\n        Add the given node to this Graph with an optional unique name,\n        and resolve attributes expressions.\n        \"\"\"\n        self._addNode(node, uniqueName if uniqueName else self._createUniqueNodeName(node.nodeType))\n        # Resolve attribute expressions\n        with GraphModification(self):\n            node._applyExpr()\n        return node\n\n    def renameNode(self, node: Node, newName: str):\n        \"\"\" Rename a node in the Node Graph.\n        If the proposed name is already assigned to a node then it will create a unique name\n\n        Args:\n            node (Node): Node to rename.\n            newName (str): New name of the node.\n        \"\"\"\n        # Handle empty string\n        if not newName:\n            return\n        if node.getLocked():\n            logging.warning(f\"Cannot rename node {node} because of the locked status\")\n            return\n        usedNames = {n._name for n in self._nodes if n != node}\n        # Make sure we rename to an available name\n        if newName in usedNames:\n            newName = self._createUniqueNodeName(newName, usedNames)\n        # Rename in the dict model\n        self._nodes.rename(node._name, newName)\n        # Finally rename the node name property and notify Qt\n        node._name = newName\n        node.nodeNameChanged.emit()\n\n    def copyNode(self, srcNode: Node, withEdges: bool=False):\n        \"\"\"\n        Get a copy instance of a node outside the graph.\n\n        Args:\n            srcNode: the node to copy\n            withEdges: whether to copy edges\n\n        Returns:\n            The created node instance and the mapping of skipped edges per attribute\n            (always empty if `withEdges` is True)\n        \"\"\"\n        def _removeLinkExpressions(attribute: Attribute, removed: dict[Attribute, str]):\n            \"\"\" Recursively remove link expressions from the given root `attribute`. \"\"\"\n            # Link expressions are only stored on input attributes\n            if attribute.isOutput:\n                return\n\n            if attribute._linkExpression:\n                removed[attribute] = attribute._linkExpression\n                attribute._linkExpression = None\n            elif isinstance(attribute, (ListAttribute, GroupAttribute)):\n                for child in attribute.value:\n                    _removeLinkExpressions(child, removed)\n\n        with GraphModification(self):\n            node = nodeFactory(srcNode.toDict(), name=srcNode.nodeType)\n\n            skippedEdges = {}\n            if not withEdges:\n                for _, attr in node.attributes.items():\n                    _removeLinkExpressions(attr, skippedEdges)\n        return node, skippedEdges\n\n    def duplicateNodes(self, srcNodes):\n        \"\"\" Duplicate nodes in the graph with their connections.\n\n        Args:\n            srcNodes: the nodes to duplicate\n\n        Returns:\n            OrderedDict[Node, Node]: the source->duplicate map\n        \"\"\"\n        # use OrderedDict to keep duplicated nodes creation order\n        duplicates = OrderedDict()\n\n        with GraphModification(self):\n            duplicateEdges = {}\n            # first, duplicate all nodes without edges and keep a 'source=>duplicate' map\n            # keeps tracks of non-created edges for later remap\n            for srcNode in srcNodes:\n                node, edges = self.copyNode(srcNode, withEdges=False)\n                duplicate = self.addNode(node)\n                duplicateEdges.update(edges)\n                duplicates.setdefault(srcNode, []).append(duplicate)\n\n            # re-create edges taking into account what has been duplicated\n            for attr, linkExpression in duplicateEdges.items():\n                # logging.warning(\"attr={} linkExpression={}\".format(attr.rootName, linkExpression))\n                link = linkExpression[1:-1]  # remove starting '{' and trailing '}'\n                # get source node and attribute name\n                edgeSrcNodeName, edgeSrcAttrName = link.split(\".\", 1)\n                edgeSrcNode = self.node(edgeSrcNodeName)\n                # if the edge's source node has been duplicated (the key exists in the dictionary),\n                # use the duplicate; otherwise use the original node\n                if edgeSrcNode in duplicates:\n                    edgeSrcNode = duplicates.get(edgeSrcNode)[0]\n                self.addEdge(edgeSrcNode.attribute(edgeSrcAttrName), attr)\n\n        return duplicates\n\n    def outEdges(self, attribute):\n        \"\"\" Return the list of edges starting from the given attribute \"\"\"\n        # type: (Attribute,) -> [Edge]\n        return [edge for edge in self.edges if edge.src == attribute]\n\n    def nodeInEdges(self, node):\n        # type: (Node) -> [Edge]\n        \"\"\" Return the list of edges arriving to this node \"\"\"\n        return [edge for edge in self.edges if edge.dst.node == node]\n\n    def nodeOutEdges(self, node):\n        # type: (Node) -> [Edge]\n        \"\"\" Return the list of edges starting from this node \"\"\"\n        return [edge for edge in self.edges if edge.src.node == node]\n\n    @changeTopology\n    def removeNode(self, nodeName):\n        \"\"\"\n        Remove the node identified by 'nodeName' from the graph.\n        Returns:\n            - a dictionary containing the incoming edges removed by this operation:\n                {dstAttr.fullName, srcAttr.fullName}\n            - a dictionary containing the outgoing edges removed by this operation:\n                {dstAttr.fullName, srcAttr.fullName}\n            - a dictionary containing the values, indices and keys of attributes that were connected to a ListAttribute\n                prior to the removal of all edges:\n                {dstAttr.fullName, (dstAttr.root.fullName, dstAttr.index, dstAttr.value)}\n        \"\"\"\n        node = self.node(nodeName)\n        inEdges = {}\n        outEdges = {}\n        outListAttributes = {}\n\n        # Remove all edges arriving to and starting from this node\n        with GraphModification(self):\n            # Two iterations over the outgoing edges are necessary:\n            # - the first one is used to collect all the information about the edges while they are all there\n            #   (overall context)\n            # - once we have collected all the information, the edges (and perhaps the entries in ListAttributes) can\n            #   actually be removed\n            for edge in self.nodeOutEdges(node):\n                outEdges[edge.dst.fullName] = edge.src.fullName\n\n                if isinstance(edge.dst.root, ListAttribute):\n                    index = edge.dst.root.index(edge.dst)\n                    outListAttributes[edge.dst.fullName] = (edge.dst.root.fullName,\n                                                            index, edge.dst.value\n                                                            if edge.dst.value else None)\n\n            for edge in self.nodeOutEdges(node):\n                self.removeEdge(edge.dst)\n\n                # Remove the corresponding attributes from the ListAttributes instead of just emptying their values\n                if isinstance(edge.dst.root, ListAttribute):\n                    index = edge.dst.root.index(edge.dst)\n                    edge.dst.root.remove(index)\n\n            for edge in self.nodeInEdges(node):\n                self.removeEdge(edge.dst)\n                inEdges[edge.dst.fullName] = edge.src.fullName\n\n            node.alive = False\n            self._nodes.remove(node)\n            self.update()\n\n        return inEdges, outEdges, outListAttributes\n\n    def addNewNode(\n        self, nodeType: str, name: Optional[str] = None, position: Optional[str] = None, **kwargs\n    ) -> Node:\n        \"\"\"\n        Create and add a new node to the graph.\n\n        Args:\n            nodeType: the node type name.\n            name: if specified, the desired name for this node. If not unique, will be prefixed (_N).\n            position: the position of the node.\n            **kwargs: keyword arguments to initialize the created node's attributes.\n\n        Returns:\n             The newly created node.\n        \"\"\"\n        if name and name in self._nodes.keys():\n            name = self._createUniqueNodeName(name)\n\n        node = self.addNode(getNodeConstructor(nodeType, position=position, **kwargs), uniqueName=name)\n        node.updateInternals()\n        self._triggerNodeCreatedCallback([node])\n        return node\n\n    def _triggerNodeCreatedCallback(self, nodes: Iterable[Node]):\n        \"\"\"\n        Trigger the `onNodeCreated` node descriptor callback for each node instance in `nodes`.\n        \"\"\"\n        with GraphModification(self):\n            for node in nodes:\n                if node.nodeDesc:\n                    node.nodeDesc.onNodeCreated(node)\n\n    def _createUniqueNodeName(self, inputName: str, existingNames: Optional[set[str]] = None):\n        \"\"\"\n        Create a unique node name based on the input name.\n\n        Args:\n            inputName: The desired node name.\n            existingNames: (optional) If specified, consider this set for uniqueness check, instead of the list of nodes.\n        \"\"\"\n        existingNodeNames = existingNames or set(self._nodes.objects.keys())\n\n        idx = 1\n        while idx:\n            newName = f\"{inputName}_{idx}\"\n            if newName not in existingNodeNames:\n                return newName\n            idx += 1\n\n    def node(self, nodeName) -> Optional[Node]:\n        return self._nodes.get(nodeName)\n\n    def upgradeNode(self, nodeName) -> Node:\n        \"\"\"\n        Upgrade the CompatibilityNode identified as 'nodeName'\n        Args:\n            nodeName (str): the name of the CompatibilityNode to upgrade\n\n        Returns:\n            - the upgraded (newly created) node\n            - a dictionary containing the incoming edges removed by this operation:\n                {dstAttr.fullName, srcAttr.fullName}\n            - a dictionary containing the outgoing edges removed by this operation:\n                {dstAttr.fullName, srcAttr.fullName}\n            - a dictionary containing the values, indices and keys of attributes that were connected to a ListAttribute\n                prior to the removal of all edges:\n                {dstAttr.fullName, (dstAttr.root.fullName, dstAttr.index, dstAttr.value)}\n        \"\"\"\n        node = self.node(nodeName)\n        if not isinstance(node, CompatibilityNode):\n            raise ValueError(\"Upgrade is only available on CompatibilityNode instances.\")\n        upgradedNode = node.upgrade()\n        self.replaceNode(nodeName, upgradedNode)\n        return upgradedNode\n\n    @changeTopology\n    def replaceNode(self, nodeName: str, newNode: BaseNode):\n        \"\"\"\n        Replace the node idenfitied by `nodeName` with `newNode`, while restoring compatible edges.\n\n        Args:\n            nodeName: The name of the Node to replace.\n            newNode: The Node instance to replace it with.\n        \"\"\"\n        with GraphModification(self):\n            _, outEdges, outListAttributes = self.removeNode(nodeName)\n            self.addNode(newNode, nodeName)\n            self._restoreOutEdges(outEdges, outListAttributes)\n\n    def _restoreOutEdges(self, outEdges: dict[str, str], outListAttributes):\n        \"\"\"\n        Restore output edges that were removed during a call to \"removeNode\".\n\n        Args:\n            outEdges: a dictionary containing the outgoing edges removed by a call to \"removeNode\".\n                {dstAttr.fullName, srcAttr.fullName}\n            outListAttributes: a dictionary containing the values, indices and keys of attributes that were connected\n                to a ListAttribute prior to the removal of all edges.\n                {dstAttr.fullName, (dstAttr.root.fullName, dstAttr.index, dstAttr.value)}\n        \"\"\"\n        def _recreateTargetListAttributeChildren(listAttrName: str, index: int, value: Any):\n            listAttr = self.attribute(listAttrName)\n            if not isinstance(listAttr, ListAttribute):\n                return\n            if isinstance(value, list):\n                listAttr[index:index] = value\n            else:\n                listAttr.insert(index, value)\n\n        for dstName, srcName in outEdges.items():\n            # Re-create the entries in ListAttributes that were completely removed during the call to \"removeNode\"\n            if dstName in outListAttributes:\n                _recreateTargetListAttributeChildren(*outListAttributes[dstName])\n            try:\n                srcAttr = self.attribute(srcName)\n                dstAttr = self.attribute(dstName)\n                if srcAttr is None or dstAttr is None:\n                    logging.warning(f\"Failed to restore edge {srcName}{' (missing)' if srcAttr is None else ''} -> {dstName}{' (missing)' if dstAttr is None else ''}\")\n                    continue\n                self.addEdge(srcAttr, dstAttr)\n            except (KeyError, ValueError) as err:\n                logging.warning(f\"Failed to restore edge {srcName} -> {dstName}: {err}\")\n\n    def upgradeAllNodes(self):\n        \"\"\" Upgrade all upgradable CompatibilityNode instances in the graph. \"\"\"\n        nodeNames = [name for name, n in self._compatibilityNodes.items() if n.canUpgrade]\n        with GraphModification(self):\n            for nodeName in nodeNames:\n                self.upgradeNode(nodeName)\n\n    def reloadNodePlugins(self, nodeTypes: list[str]):\n        \"\"\"\n        Replace all the node instances of \"nodeTypes\" in the current graph with new node instances of the\n        same type. If the description of the nodes has changed, the reloaded nodes will reflect theses\n        changes. If \"nodeTypes\" is empty, then the function returns immediately.\n\n        Args:\n            nodeTypes: the list of node types that will be reloaded.\n        \"\"\"\n        if not nodeTypes:\n            # No updated node to replace in the graph, nothing to do\n            return\n\n        newNodes: dict[str, BaseNode] = {}\n        for node in self._nodes.values():\n            if node.nodeType in nodeTypes:\n                newNode = nodeFactory(node.toDict(), node.nodeType, expectedUid=node._uid)\n                newNodes[node.name] = newNode\n\n        # Replace in a different loop to ensure all the nodes have been looped over: when looping\n        # over self._nodes and replacing nodes at the same time, some nodes might not be reached\n        for name, node in newNodes.items():\n            self.replaceNode(name, node)\n\n    @Slot(str, result=Attribute)\n    def attribute(self, fullName):\n        # type: (str) -> Attribute\n        \"\"\"\n        Return the attribute identified by the unique name 'fullName'.\n        If it does not exist, return None.\n        \"\"\"\n        node, attribute = fullName.split('.', 1)\n        if self.node(node).hasAttribute(attribute):\n            return self.node(node).attribute(attribute)\n        return None\n\n    @Slot(str, result=Attribute)\n    def internalAttribute(self, fullName):\n        # type: (str) -> Attribute\n        \"\"\"\n        Return the internal attribute identified by the unique name 'fullName'.\n        If it does not exist, return None.\n        \"\"\"\n        node, attribute = fullName.split('.', 1)\n        if self.node(node).hasInternalAttribute(attribute):\n            return self.node(node).internalAttribute(attribute)\n        return None\n\n    @staticmethod\n    def getNodeIndexFromName(name):\n        \"\"\" Nodes are created with a suffix index; returns this index by parsing node name.\n\n        Args:\n            name (str): the node name\n        Returns:\n             int: the index retrieved from node name (-1 if not found)\n        \"\"\"\n        try:\n            return int(name.split('_')[-1])\n        except Exception:\n            return -1\n\n    @staticmethod\n    def sortNodesByIndex(nodes):\n        \"\"\"\n        Sort the given list of Nodes using the suffix index in their names.\n        [NodeName_1, NodeName_0] => [NodeName_0, NodeName_1]\n\n        Args:\n            nodes (list[Node]): the list of Nodes to sort\n        Returns:\n            list[Node]: the sorted list of Nodes based on their index\n        \"\"\"\n        return sorted(nodes, key=lambda x: Graph.getNodeIndexFromName(x.name))\n\n    def nodesOfType(self, nodeType, sortedByIndex=True):\n        \"\"\"\n        Returns all Nodes of the given nodeType.\n\n        Args:\n            nodeType (str): the node type name to consider.\n            sortedByIndex (bool): whether to sort the nodes by their index (see Graph.sortNodesByIndex)\n        Returns:\n            list[Node]: the list of nodes matching the given nodeType.\n        \"\"\"\n        nodes = [n for n in self._nodes.values() if n.nodeType == nodeType]\n        return self.sortNodesByIndex(nodes) if sortedByIndex else nodes\n\n    def findInitNodes(self):\n        \"\"\"\n        Returns:\n            list[Node]: the list of Init nodes (nodes inheriting from InitNode)\n        \"\"\"\n        nodes = [n for n in self._nodes.values() if isinstance(n.nodeDesc, meshroom.core.desc.InitNode)]\n        return nodes\n\n    def findNodeCandidates(self, nodeNameExpr: str) -> list[Node]:\n        pattern = re.compile(nodeNameExpr)\n        return [v for k, v in self._nodes.objects.items() if pattern.match(k)]\n\n    def findNode(self, nodeExpr: str) -> Node:\n        candidates = self.findNodeCandidates('^' + nodeExpr)\n        if not candidates:\n            raise KeyError(f'No node candidate for \"{nodeExpr}\"')\n        if len(candidates) > 1:\n            for c in candidates:\n                if c.name == nodeExpr:\n                    return c\n            raise KeyError(f'Multiple node candidates for \"{nodeExpr}\": {str([c.name for c in candidates])}')\n        return candidates[0]\n\n    def findNodes(self, nodesExpr):\n        if isinstance(nodesExpr, list):\n            return [self.findNode(nodeName) for nodeName in nodesExpr]\n        return [self.findNode(nodesExpr)]\n\n    def edge(self, dstAttributeName):\n        return self._edges.get(dstAttributeName)\n\n    def getLeafNodes(self, dependenciesOnly):\n        nodesWithOutputLink = {edge.src.node for edge in self.getEdges(dependenciesOnly)}\n        return set(self._nodes) - nodesWithOutputLink\n\n    def getRootNodes(self, dependenciesOnly):\n        nodesWithInputLink = {edge.dst.node for edge in self.getEdges(dependenciesOnly)}\n        return set(self._nodes) - nodesWithInputLink\n\n    @changeTopology\n    def addEdge(self, srcAttr: Attribute, dstAttr: Attribute) -> tuple[list[Attribute], list[Attribute]]:\n        if not srcAttr.node.graph == dstAttr.node.graph == self:\n            raise InvalidEdgeError(srcAttr.fullName, dstAttr.fullName,\n                                   \"Attributes do not belong to this graph.\")\n\n        if not dstAttr.validateIncomingConnection(srcAttr):\n            raise InvalidEdgeError(srcAttr.fullName, dstAttr.fullName,\n                                   f\"Attributes are not compatible (src base type: {srcAttr.baseType}; dst base type: {dstAttr.baseType}).\")\n\n        deletedEdge = []\n        if dstAttr in self.edges.keys():\n            deletedEdge = self.removeEdge(dstAttr)\n        edge = Edge(srcAttr, dstAttr)\n        self.edges.add(edge)\n        self.markNodesDirty(dstAttr.node)\n        dstAttr.valueChanged.emit()\n        dstAttr.inputLinksChanged.emit()\n        srcAttr.outputLinksChanged.emit()\n        return [edge.src, edge.dst], deletedEdge\n\n    @changeTopology\n    def removeEdge(self, dstAttr: Attribute):\n        if not self.edges.get(dstAttr):\n            return None\n\n        edge = self.edges.pop(dstAttr)\n        self.markNodesDirty(dstAttr.node)\n        dstAttr.valueChanged.emit()\n        dstAttr.inputLinksChanged.emit()\n        edge.src.outputLinksChanged.emit()\n        return [edge.src, dstAttr]\n\n    def getDepth(self, node, minimal=False):\n        \"\"\" Return node's depth in this Graph.\n        By default, returns the maximal depth of the node unless minimal is set to True.\n\n        Args:\n            node (Node): the node to consider.\n            minimal (bool): whether to return the minimal depth instead of the maximal one (default).\n        Returns:\n            int: the node's depth in this Graph.\n        \"\"\"\n        assert node.graph == self\n        assert not self.dirtyTopology\n        minDepth, maxDepth = self._nodesMinMaxDepths[node]\n        return minDepth if minimal else maxDepth\n\n    def getInputEdges(self, node, dependenciesOnly):\n        return {edge for edge in self.getEdges(dependenciesOnly=dependenciesOnly) if edge.dst.node is node}\n\n    def _getInputEdgesPerNode(self, dependenciesOnly):\n        nodeEdges = defaultdict(set)\n\n        for edge in self.getEdges(dependenciesOnly=dependenciesOnly):\n            nodeEdges[edge.dst.node].add(edge.src.node)\n\n        return nodeEdges\n\n    def _getOutputEdgesPerNode(self, dependenciesOnly):\n        nodeEdges = defaultdict(set)\n\n        for edge in self.getEdges(dependenciesOnly=dependenciesOnly):\n            nodeEdges[edge.src.node].add(edge.dst.node)\n\n        return nodeEdges\n\n    def dfs(self, visitor, startNodes=None, longestPathFirst=False):\n        # Default direction (visitor.reverse=False): from node to root\n        # Reverse direction (visitor.reverse=True): from node to leaves\n        nodeChildren = self._getOutputEdgesPerNode(visitor.dependenciesOnly) \\\n                       if visitor.reverse else self._getInputEdgesPerNode(visitor.dependenciesOnly)\n        # Initialize color map\n        colors = {}\n        for u in self._nodes:\n            colors[u] = WHITE\n\n        if longestPathFirst and visitor.reverse:\n            # Because we have no knowledge of the node's count between a node and its leaves,\n            # it is not possible to handle this case at the moment\n            raise NotImplementedError(\"Graph.dfs(): longestPathFirst=True and visitor.reverse=True are not \"\n                                      \"compatible yet.\")\n\n        nodes = startNodes or (self.getRootNodes(visitor.dependenciesOnly)\n                               if visitor.reverse else self.getLeafNodes(visitor.dependenciesOnly))\n\n        if longestPathFirst:\n            # Graph topology must be known and node depths up-to-date\n            assert not self.dirtyTopology\n            nodes = sorted(nodes, key=lambda item: item.depth)\n\n        try:\n            for node in nodes:\n                self.dfsVisit(node, visitor, colors, nodeChildren, longestPathFirst)\n        except StopGraphVisit:\n            pass\n\n    def dfsVisit(self, u, visitor, colors, nodeChildren, longestPathFirst):\n        try:\n            self._dfsVisit(u, visitor, colors, nodeChildren, longestPathFirst)\n        except StopBranchVisit:\n            pass\n\n    def _dfsVisit(self, u, visitor, colors, nodeChildren, longestPathFirst):\n        colors[u] = GRAY\n        visitor.discoverVertex(u, self)\n        # d_time[u] = time = time + 1\n        children = nodeChildren[u]\n        if longestPathFirst:\n            assert not self.dirtyTopology\n            children = sorted(children, reverse=True, key=lambda item: self._nodesMinMaxDepths[item][1])\n        for v in children:\n            visitor.examineEdge((u, v), self)\n            if colors[v] == WHITE:\n                visitor.treeEdge((u, v), self)\n                # (u,v) is a tree edge\n                self.dfsVisit(v, visitor, colors, nodeChildren, longestPathFirst)  # TODO: avoid recursion\n            elif colors[v] == GRAY:\n                # (u,v) is a back edge\n                visitor.backEdge((u, v), self)\n            elif colors[v] == BLACK:\n                # (u,v) is a cross or forward edge\n                visitor.forwardOrCrossEdge((u, v), self)\n            visitor.finishEdge((u, v), self)\n        colors[u] = BLACK\n        visitor.finishVertex(u, self)\n\n    def dfsOnFinish(self, startNodes=None, longestPathFirst=False, reverse=False, dependenciesOnly=False):\n        \"\"\"\n        Return the node chain from startNodes to the graph roots/leaves.\n        Order is defined by the visit and finishVertex event.\n\n        Args:\n            startNodes (Node list): the nodes to start the visit from.\n            longestPathFirst (bool): (optional) if multiple paths, nodes belonging to\n                            the longest one will be visited first.\n            reverse (bool): (optional) direction of visit.\n                            True is for getting nodes depending on the startNodes (to leaves).\n                            False is for getting nodes required for the startNodes (to roots).\n        Returns:\n            The list of nodes and edges, from startNodes to the graph roots/leaves following edges.\n        \"\"\"\n        nodes = []\n        edges = []\n        visitor = Visitor(reverse=reverse, dependenciesOnly=dependenciesOnly)\n        visitor.finishVertex = lambda vertex, graph: nodes.append(vertex)\n        visitor.finishEdge = lambda edge, graph: edges.append(edge)\n        self.dfs(visitor=visitor, startNodes=startNodes, longestPathFirst=longestPathFirst)\n        return nodes, edges\n\n    def dfsOnDiscover(self, startNodes=None, filterTypes=None, longestPathFirst=False, reverse=False, dependenciesOnly=False):\n        \"\"\"\n        Return the node chain from startNodes to the graph roots/leaves.\n        Order is defined by the visit and discoverVertex event.\n\n        Args:\n            startNodes (Node list): the nodes to start the visit from.\n            filterTypes (str list): (optional) only return the nodes of the given types\n                              (does not stop the visit, this is a post-process only)\n            longestPathFirst (bool): (optional) if multiple paths, nodes belonging to\n                            the longest one will be visited first.\n            reverse (bool): (optional) direction of visit.\n                            True is for getting nodes depending on the startNodes (to leaves).\n                            False is for getting nodes required for the startNodes (to roots).\n        Returns:\n            The list of nodes and edges, from startNodes to the graph roots/leaves following edges.\n        \"\"\"\n        nodes = []\n        edges = []\n        visitor = Visitor(reverse=reverse, dependenciesOnly=dependenciesOnly)\n\n        def discoverVertex(vertex, graph):\n            if not filterTypes or vertex.nodeType in filterTypes:\n                nodes.append(vertex)\n\n        visitor.discoverVertex = discoverVertex\n        visitor.examineEdge = lambda edge, graph: edges.append(edge)\n        self.dfs(visitor=visitor, startNodes=startNodes, longestPathFirst=longestPathFirst)\n        return nodes, edges\n\n    def dfsToProcess(self, startNodes=None):\n        \"\"\"\n        Return the full list of predecessor nodes to process in order to compute the given nodes.\n\n        Args:\n            startNodes: list of starting nodes. Use all leaves if empty.\n\n        Returns:\n             visited nodes and edges that are not already computed (node.status != SUCCESS).\n             The order is defined by the visit and finishVertex event.\n        \"\"\"\n        nodes = []\n        edges = []\n        visitor = Visitor(reverse=False, dependenciesOnly=True)\n\n        def discoverVertex(vertex, graph):\n            if vertex.hasStatus(Status.SUCCESS):\n                # stop branch visit if discovering a node already computed\n                raise StopBranchVisit()\n\n        def finishVertex(vertex, graph):\n            if not vertex.chunks:\n                # Chunks have not been initialized\n                nodes.append(vertex)\n                return\n            chunksToProcess = []\n            for chunk in vertex.chunks:\n                if chunk.status.status is not Status.SUCCESS:\n                    chunksToProcess.append(chunk)\n            if chunksToProcess:\n                nodes.append(vertex)  # We could collect specific chunks\n\n        def finishEdge(edge, graph):\n            if edge[0].isComputed or edge[1].isComputed:\n                return\n            edges.append(edge)\n\n        visitor.finishVertex = finishVertex\n        visitor.finishEdge = finishEdge\n        visitor.discoverVertex = discoverVertex\n        self.dfs(visitor=visitor, startNodes=startNodes)\n        return nodes, edges\n\n    @Slot(Node, result=bool)\n    def canComputeTopologically(self, node):\n        \"\"\"\n        Return the computability of a node based on itself and its dependency chain.\n        It is a static result as it depends on the graph topology.\n        Computation cannot happen for:\n         - CompatibilityNodes\n         - nodes having a non-computed CompatibilityNode in its dependency chain\n\n        Args:\n            node (Node): the node to evaluate\n\n        Returns:\n            bool: whether the node can be computed\n        \"\"\"\n        if isinstance(node, CompatibilityNode):\n            return False\n        return not self._computationBlocked[node]\n\n    def updateNodesTopologicalData(self):\n        \"\"\"\n        Compute and cache nodes topological data:\n            - min and max depth\n            - computability\n        \"\"\"\n\n        self._nodesMinMaxDepths.clear()\n        self._computationBlocked.clear()\n\n        compatNodes = []\n        visitor = Visitor(reverse=False, dependenciesOnly=False)\n\n        def discoverVertex(vertex, graph):\n            # initialize depths\n            self._nodesMinMaxDepths[vertex] = (0, 0)\n            # initialize computability\n            self._computationBlocked[vertex] = False\n            if isinstance(vertex, CompatibilityNode):\n                compatNodes.append(vertex)\n                # a not computed CompatibilityNode blocks computation\n                if not vertex.hasStatus(Status.SUCCESS):\n                    self._computationBlocked[vertex] = True\n\n        def finishEdge(edge, graph):\n            currentVertex, inputVertex = edge\n\n            # update depths\n            currentDepths = self._nodesMinMaxDepths[currentVertex]\n            inputDepths = self._nodesMinMaxDepths[inputVertex]\n            if currentDepths[0] == 0:\n                # if not initialized, set the depth of the first child\n                depthMin = inputDepths[0] + 1\n            else:\n                depthMin = min(currentDepths[0], inputDepths[0] + 1)\n            self._nodesMinMaxDepths[currentVertex] = (depthMin, max(currentDepths[1], inputDepths[1] + 1))\n\n            # update computability\n            if currentVertex.hasStatus(Status.SUCCESS):\n                # output is already computed and available,\n                # does not depend on input connections computability\n                return\n            # propagate inputVertex computability\n            self._computationBlocked[currentVertex] |= self._computationBlocked[inputVertex]\n\n        leaves = self.getLeafNodes(visitor.dependenciesOnly)\n        visitor.finishEdge = finishEdge\n        visitor.discoverVertex = discoverVertex\n        self.dfs(visitor=visitor, startNodes=leaves)\n\n        # update graph computability status\n        canComputeLeaves = all([self.canComputeTopologically(node) for node in leaves])\n        if self._canComputeLeaves != canComputeLeaves:\n            self._canComputeLeaves = canComputeLeaves\n            self.canComputeLeavesChanged.emit()\n\n        # update compatibilityNodes model\n        if len(self._compatibilityNodes) != len(compatNodes):\n            self._compatibilityNodes.reset(compatNodes)\n\n    compatibilityNodes = Property(BaseObject, lambda self: self._compatibilityNodes, constant=True)\n\n    def dfsMaxEdgeLength(self, startNodes=None, dependenciesOnly=True):\n        \"\"\"\n        :param startNodes: list of starting nodes. Use all leaves if empty.\n        :return:\n        \"\"\"\n        nodesStack = []\n        edgesScore = defaultdict(int)\n        visitor = Visitor(reverse=False, dependenciesOnly=dependenciesOnly)\n\n        def finishEdge(edge, graph):\n            u, v = edge\n            for i, n in enumerate(reversed(nodesStack)):\n                index = i + 1\n                if index > edgesScore[(n, v)]:\n                    edgesScore[(n, v)] = index\n\n        def finishVertex(vertex, graph):\n            v = nodesStack.pop()\n            assert v == vertex\n\n        visitor.discoverVertex = lambda vertex, graph: nodesStack.append(vertex)\n        visitor.finishVertex = finishVertex\n        visitor.finishEdge = finishEdge\n        self.dfs(visitor=visitor, startNodes=startNodes, longestPathFirst=True)\n        return edgesScore\n\n    def flowEdges(self, startNodes=None, dependenciesOnly=True):\n        \"\"\"\n        Return as few edges as possible, such that if there is a directed path from one vertex to another in the\n        original graph, there is also such a path in the reduction.\n\n        :param startNodes:\n        :return: the remaining edges after a transitive reduction of the graph.\n        \"\"\"\n        flowEdges = []\n        edgesScore = self.dfsMaxEdgeLength(startNodes, dependenciesOnly)\n\n        for link, score in edgesScore.items():\n            assert score != 0\n            if score == 1:\n                flowEdges.append(link)\n        return flowEdges\n\n    def getEdges(self, dependenciesOnly=False):\n        if not dependenciesOnly:\n            return self.edges\n\n        outEdges = []\n        for e in self.edges:\n            attr = e.src\n            if dependenciesOnly:\n                if attr.isLink:\n                    attr = attr.inputRootLink\n                if not attr.isOutput:\n                    continue\n            newE = Edge(attr, e.dst)\n            outEdges.append(newE)\n        return outEdges\n\n    def getInputNodes(self, node, recursive, dependenciesOnly):\n        \"\"\" Return either the first level input nodes of a node or the whole chain. \"\"\"\n        if not recursive:\n            return {edge.src.node for edge in self.getEdges(dependenciesOnly) if edge.dst.node is node}\n\n        inputNodes, edges = self.dfsOnDiscover(startNodes=[node], filterTypes=None, reverse=False)\n        return inputNodes[1:]  # exclude current node\n\n    def getOutputNodes(self, node, recursive, dependenciesOnly):\n        \"\"\" Return either the first level output nodes of a node or the whole chain. \"\"\"\n        if not recursive:\n            return {edge.dst.node for edge in self.getEdges(dependenciesOnly) if edge.src.node is node}\n\n        outputNodes, edges = self.dfsOnDiscover(startNodes=[node], filterTypes=None, reverse=True)\n        return outputNodes[1:]  # exclude current node\n\n    @Slot(Node, result=int)\n    def canSubmitOrCompute(self, startNode):\n        \"\"\"\n        Check if a node can be submitted/computed.\n        It does not depend on the topology of the graph and is based on the node status and its dependencies.\n\n        Returns:\n            int: 0 = cannot be submitted or computed /\n                1 = can be computed /\n                2 = can be submitted /\n                3 = can be submitted and computed\n        \"\"\"\n        if startNode.isAlreadySubmittedOrFinished():\n            return 0\n\n        class SCVisitor(Visitor):\n            def __init__(self, reverse, dependenciesOnly):\n                super().__init__(reverse, dependenciesOnly)\n\n            canCompute = True\n            canSubmit = True\n\n            def discoverVertex(self, vertex, graph):\n                if vertex.isAlreadySubmitted():\n                    self.canSubmit = False\n                    if vertex.isExtern():\n                        self.canCompute = False\n\n        visitor = SCVisitor(reverse=False, dependenciesOnly=True)\n        self.dfs(visitor=visitor, startNodes=[startNode])\n        return visitor.canCompute + (2 * visitor.canSubmit)\n\n    def _applyExpr(self):\n        with GraphModification(self):\n            for node in self._nodes:\n                node._applyExpr()\n\n    def toDict(self):\n        nodes = {k: node.toDict() for k, node in self._nodes.objects.items()}\n        nodes = dict(sorted(nodes.items()))\n        return nodes\n\n    @Slot(result=str)\n    def asString(self):\n        return str(self.toDict())\n\n    def copy(self) -> \"Graph\":\n        \"\"\" Create a copy of this Graph instance. \"\"\"\n        graph = Graph(\"\")\n        graph._deserialize(self.serialize())\n        return graph\n\n    def serialize(self, asTemplate: bool = False) -> dict:\n        \"\"\"\n        Serialize this Graph instance.\n\n        Args:\n            asTemplate: Whether to use the template serialization.\n\n        Returns:\n            The serialized graph data.\n        \"\"\"\n        SerializerClass = TemplateGraphSerializer if asTemplate else GraphSerializer\n        return SerializerClass(self).serialize()\n\n    def serializePartial(self, nodes: list[Node]) -> dict:\n        \"\"\"\n        Partially serialize this graph considering only the given list of `nodes`.\n\n        Args:\n            nodes: The list of nodes to serialize.\n\n        Returns:\n            The serialized graph data.\n        \"\"\"\n        return PartialGraphSerializer(self, nodes=nodes).serialize()\n\n    def save(self, filepath=None, setupProjectFile=True, template=False):\n        \"\"\"\n        Save the current Meshroom graph as a serialized \".mg\" file.\n\n        Args:\n            filepath: project filepath to save as.\n            setupProjectFile: Store the reference to the project file and setup the cache directory.\n                              If false, it only saves the graph of the project file as a template.\n            template: If true, saves the current graph as a template.\n        \"\"\"\n        # Update the saving flag indicating that the current graph is being saved\n        self._saving = True\n        try:\n            self._save(filepath=filepath, setupProjectFile=setupProjectFile, template=template)\n        finally:\n            self._saving = False\n    \n    def _generateNextPath(self):\n        \"\"\"\n        Generate the filename for the next version\n        - scene.mg -> scene1.mg\n        - scene1.mg -> scene2.mg\n        - scene_001.mg -> scene_002.mg (preserves zero-padding)\n        - scene1.mg and scene2.mg exists -> scene3.mg\n        \"\"\"\n        path = Path(self._filepath)\n        stem, ext = path.stem, path.suffix\n        # Match name and version number at the end\n        versionMatch = re.match(r'^(.+?)(\\d+)$', stem)\n        if versionMatch:\n            stemBase, versionStr = versionMatch.group(1), versionMatch.group(2)\n            version = int(versionStr) + 1\n            # Preserve zero-padding from original\n            padding = len(versionStr)\n        else:\n            stemBase, version, padding = stem, 1, 1\n        # Find an available name\n        while True:\n            # Format version number with appropriate padding\n            versionStr = str(version).zfill(padding)\n            pathCandidate = path.parent / f\"{stemBase}{versionStr}{ext}\"\n            if not pathCandidate.exists():\n                return str(pathCandidate)\n            version += 1\n\n    def saveAsNewVersion(self):\n        \"\"\"\n        Increase the version of the file and save\n        \"\"\"\n        # Generate the new version path\n        path = self._generateNextPath()\n        # Update the saving flag indicating that the current graph is being saved\n        self._saving = True\n        try:\n            self._save(filepath=path)\n        finally:\n            self._saving = False\n\n    def _save(self, filepath=None, setupProjectFile=True, template=False):\n        path = filepath or self._filepath\n        if not path:\n            path = generateTempProjectFilepath()\n\n        data = self.serialize(template)\n\n        with open(path, 'w') as jsonFile:\n            json.dump(data, jsonFile, indent=4)\n\n        if path != self._filepath and setupProjectFile:\n            self._setFilepath(path)\n\n        # update the file date version\n        self._fileDateVersion = os.path.getmtime(path)\n\n    def saveAsTemp(self, tmpFolder=None):\n        \"\"\"\n        Save the current Meshroom graph as a temporary project file.\n        \"\"\"\n        # Update the saving flag indicating that the current graph is being saved\n        self._saving = True\n        try:\n            self._saveAsTemp(tmpFolder)\n        finally:\n            self._saving = False\n\n    def _saveAsTemp(self, tmpFolder=None):\n        projectPath = generateTempProjectFilepath(tmpFolder)\n        self._save(projectPath)\n\n    def _setFilepath(self, filepath):\n        \"\"\"\n        Set the internal filepath of this Graph.\n        This method should not be used directly from outside, use save/load instead.\n        Args:\n            filepath: the graph file path\n        \"\"\"\n        if not os.path.isfile(filepath):\n            self._unsetFilepath()\n            return\n\n        # Make sure the path is stored using the POSIX convention\n        # so that it can be used when creating sub-processes for node execution.\n        newFilepath = Path(filepath).as_posix()\n        if self._filepath == newFilepath:\n            return\n        self._filepath = newFilepath\n        # For now:\n        #  * cache folder is located next to the graph file\n        #  * graph name if the basename of the graph file\n        self.name = os.path.splitext(os.path.basename(filepath))[0]\n        self.cacheDir = os.path.join(os.path.abspath(os.path.dirname(filepath)), meshroom.core.cacheFolderName)\n        self.filepathChanged.emit()\n\n    def _unsetFilepath(self):\n        self._filepath = \"\"\n        self.name = \"\"\n        self.cacheDir = \"\"\n        self.filepathChanged.emit()\n\n    def updateInternals(self, startNodes=None, force=False):\n        nodes, edges = self.dfsOnFinish(startNodes=startNodes)\n        for node in nodes:\n            if node.dirty or force:\n                node.updateInternals()\n\n    def updateStatusFromCache(self, force=False):\n        for node in self._nodes:\n            if node.dirty or force:\n                node.updateStatusFromCache()\n\n    def updateStatisticsFromCache(self):\n        for node in self._nodes:\n            node.updateStatisticsFromCache()\n\n    def updateNodesPerUid(self):\n        \"\"\" Update the duplicate nodes (sharing same UID) list of each node. \"\"\"\n        # First step is to construct a map UID/nodes\n        nodesPerUid = {}\n        for node in self.nodes:\n            uid = node._uid\n\n            # We try to add the node to the list corresponding to this UID\n            try:\n                nodesPerUid.get(uid).append(node)\n            # If it fails because the uid is not in the map, we add it\n            except AttributeError:\n                nodesPerUid.update({uid: [node]})\n\n        # Now, update each individual node\n        for node in self.nodes:\n            node.updateDuplicates(nodesPerUid)\n    \n    def updateJobManagerWithNode(self, node):\n        if node._uid in jobManager._nodeToJob.keys():\n            return\n        jobInfo = node._nodeStatus.jobInfo\n        if not jobInfo:\n            return\n        jid, subName = jobInfo.get(\"jid\"), jobInfo.get(\"submitterName\")\n        for _subName, sub in submitters.items():\n            if _subName == subName:\n                try:\n                    job = sub.retrieveJob(int(jid))\n                    jobManager.addJob(job, [node])\n                    break\n                except Exception as e:\n                    logging.warning(f\"Failed to retrieve job {jid} from submitter {subName} : {e}\")\n                    break\n\n    def update(self):\n        if not self._updateEnabled:\n            # To do the update once for multiple changes\n            self._updateRequested = True\n            return\n\n        self.updateInternals()\n        if os.path.exists(self._cacheDir):\n            self.updateStatusFromCache()\n        for node in self.nodes:\n            node.dirty = False\n            self.updateJobManagerWithNode(node)\n\n        self.updateNodesPerUid()\n\n        # Graph topology has changed\n        if self.dirtyTopology:\n            # update nodes topological data cache\n            self.updateNodesTopologicalData()\n            self.dirtyTopology = False\n\n        self.updated.emit()\n    \n    def updateMonitoredFiles(self):\n        self.statusUpdated.emit()\n\n    def markNodesDirty(self, fromNode):\n        \"\"\"\n        Mark all nodes following 'fromNode' as dirty.\n        All nodes marked as dirty will get their outputs to be re-evaluated\n        during the next graph update.\n\n        Args:\n            fromNode (Node): the node to start the invalidation from\n\n        See Also:\n            Graph.update, Graph.updateInternals, Graph.updateStatusFromCache\n        \"\"\"\n        nodes, edges = self.dfsOnDiscover(startNodes=[fromNode], reverse=True)\n        for node in nodes:\n            node.dirty = True\n\n    def stopExecution(self):\n        \"\"\" Request graph execution to be stopped by terminating running chunks\"\"\"\n        for node in self.nodes:\n            if node.canBeStopped():\n                for chunk in node.chunks:\n                    chunk.stopProcess()\n            elif node.canBeCanceled():\n                node.clearSubmittedChunks()\n\n    @Slot()\n    @Slot(list)\n    def forceUnlockNodes(self, nodes=None):\n        \"\"\" Force to unlock all the nodes. \"\"\"\n        nodes = nodes if nodes else self.nodes\n        for node in nodes:\n            node.setLocked(False)\n\n    @Slot()\n    @Slot(list)\n    def clearSubmittedNodes(self, nodes=None):\n        \"\"\" Reset the status of already submitted nodes to Status.NONE \"\"\"\n        nodes = nodes if nodes else self.nodes\n        for node in nodes:\n            node.clearSubmittedChunks()\n\n    def clearLocallySubmittedNodes(self):\n        \"\"\" Reset the status of already locally submitted nodes to Status.NONE \"\"\"\n        for node in self.nodes:\n            node.clearLocallySubmittedChunks()\n\n    def getChunksByStatus(self, status):\n        \"\"\" Return the list of NodeChunks with the given status. \"\"\"\n        chunks = []\n        for node in self.nodes:\n            chunks += [chunk for chunk in node.chunks if chunk.status.status == status]\n        return chunks\n\n    def getChunks(self, nodes=None):\n        \"\"\"\n        Returns the list of NodeChunks for the given list of nodes (for all nodes if nodes is None).\n        \"\"\"\n        chunks = []\n        for node in nodes or self.nodes:\n            chunks += [chunk for chunk in node.chunks]\n        return chunks\n\n    def getOrderedChunks(self):\n        \"\"\" Get chunks as visited by dfsOnFinish.\n\n        Returns:\n            list of NodeChunks: the ordered list of NodeChunks\n        \"\"\"\n        return self.getChunks(self.dfsOnFinish()[0])\n\n    @property\n    def nodes(self):\n        return self._nodes\n\n    @property\n    def edges(self):\n        return self._edges\n\n    @property\n    def cacheDir(self):\n        return self._cacheDir\n\n    @cacheDir.setter\n    def cacheDir(self, value):\n        if self._cacheDir == value:\n            return\n        # use unix-style paths for cache directory\n        self._cacheDir = value.replace(os.path.sep, \"/\")\n        self.updateInternals(force=True)\n        self.updateStatusFromCache(force=True)\n        self.cacheDirChanged.emit()\n\n    @property\n    def fileDateVersion(self):\n        return self._fileDateVersion\n\n    @fileDateVersion.setter\n    def fileDateVersion(self, value):\n        self._fileDateVersion = value\n\n    @Slot(str, result=float)\n    def getFileDateVersionFromPath(self, value):\n        return os.path.getmtime(value)\n\n    def setVerbose(self, v):\n        with GraphModification(self):\n            for node in self._nodes:\n                if node.hasAttribute('verbose'):\n                    try:\n                        node.verbose.value = v\n                    except Exception:\n                        pass\n\n    nodes = Property(BaseObject, nodes.fget, constant=True)\n    edges = Property(BaseObject, edges.fget, constant=True)\n    filepathChanged = Signal()\n    filepath = Property(str, lambda self: self._filepath, notify=filepathChanged)\n    isSaving = Property(bool, isSaving.fget, constant=True)\n    fileReleaseVersion = Property(str, lambda self: self.header.get(GraphIO.Keys.ReleaseVersion, \"0.0\"),\n                                  notify=filepathChanged)\n    fileDateVersion = Property(float, fileDateVersion.fget, fileDateVersion.fset, notify=filepathChanged)\n    cacheDirChanged = Signal()\n    cacheDir = Property(str, cacheDir.fget, cacheDir.fset, notify=cacheDirChanged)\n    updated = Signal()\n    statusUpdated = Signal()\n    canComputeLeavesChanged = Signal()\n    canComputeLeaves = Property(bool, lambda self: self._canComputeLeaves, notify=canComputeLeavesChanged)\n\n\ndef loadGraph(filepath, strictCompatibility: bool = False) -> Graph:\n    \"\"\"\n    Load a Graph from a Meshroom Graph (.mg) file.\n\n    Args:\n        filepath: The path to the Meshroom Graph file.\n        strictCompatibility: If True, raise a GraphCompatibilityError if the loaded Graph has node compatibility issues.\n\n    Returns:\n        Graph: The loaded Graph instance.\n\n    Raises:\n        GraphCompatibilityError: If the Graph has node compatibility issues and `strictCompatibility` is True.\n    \"\"\"\n    graph = Graph(\"\")\n    graph.load(filepath)\n\n    compatibilityIssues = len(graph.compatibilityNodes) > 0\n    if compatibilityIssues and strictCompatibility:\n        raise GraphCompatibilityError(filepath, {n.name: str(n.issue) for n in graph.compatibilityNodes})\n\n    graph.update()\n    return graph\n\n\ndef getAlreadySubmittedChunks(nodes):\n    out = []\n    for node in nodes:\n        for chunk in node.chunks:\n            if chunk.isAlreadySubmitted():\n                out.append(chunk)\n    return out\n\n\ndef executeGraph(graph, toNodes=None, forceCompute=False, forceStatus=False):\n    \"\"\"\n    \"\"\"\n    if forceCompute:\n        nodes, edges = graph.dfsOnFinish(startNodes=toNodes)\n    else:\n        nodes, edges = graph.dfsToProcess(startNodes=toNodes)\n        chunksInConflict = getAlreadySubmittedChunks(nodes)\n\n        if chunksInConflict:\n            chunksStatus = {chunk.status.status.name for chunk in chunksInConflict}\n            chunksName = [node.name for node in chunksInConflict]\n            msg = \"WARNING: Some nodes are already submitted with status: {}\\nNodes: {}\".format(\n                  \", \".join(chunksStatus),\n                  \", \".join(chunksName)\n                  )\n            if forceStatus:\n                print(msg)\n            else:\n                raise RuntimeError(msg)\n\n    print(\"Nodes to execute: \", str([n.name for n in nodes]))\n\n    graph.save()\n\n    for node in nodes:\n        node.initStatusOnCompute(forceCompute)\n\n    for n, node in enumerate(nodes):\n        try:\n            # If the node is in compatibility mode, it cannot be computed\n            if node.isCompatibilityNode:\n                logging.warning(f\"{node.name} is in Compatibility Mode and cannot be computed: {node.issueDetails}.\")\n                continue\n\n            node.preprocess()\n            if not node._chunksCreated:\n                node.createChunks()\n            multiChunks = len(node.chunks) > 1\n            for c, chunk in enumerate(node.chunks):\n                if multiChunks:\n                    print('\\n[{node}/{nbNodes}]({chunk}/{nbChunks}) {nodeName}'.format(\n                        node=n+1, nbNodes=len(nodes),\n                        chunk=c+1, nbChunks=len(node.chunks), nodeName=node.nodeType))\n                else:\n                    print(f'\\n[{n + 1}/{len(nodes)}] {node.nodeType}')\n                chunk.process(forceCompute)\n            node.postprocess()\n        except Exception as exc:\n            logging.error(f\"Error on node computation: {exc}\")\n            graph.clearSubmittedNodes()\n            raise\n\n    for node in nodes:\n        node.endSequence()\n\n\ndef submitGraph(graph, submitter, toNodes=None, submitLabel=\"{projectName}\"):\n    nodesToProcess, edgesToProcess = graph.dfsToProcess(startNodes=toNodes)\n    flowEdges = graph.flowEdges(startNodes=toNodes)\n    edgesToProcess = set(edgesToProcess).intersection(flowEdges)\n\n    if not nodesToProcess:\n        logging.warning('Nothing to compute')\n        return\n\n    logging.info(f\"Nodes to process: {edgesToProcess}\")\n    logging.info(f\"Edges to process: {edgesToProcess}\")\n\n    sub = None\n    if submitter:\n        sub = meshroom.core.submitters.get(submitter, None)\n    elif len(meshroom.core.submitters) == 1:\n        # if only one submitter available use it\n        sub = meshroom.core.submitters.values()[0]\n    if sub is None:\n        raise RuntimeError(\"Unknown Submitter: '{submitter}'. Available submitters are: '{allSubmitters}'.\".format(\n            submitter=submitter, allSubmitters=str(meshroom.core.submitters.keys())))\n\n    for node in nodesToProcess:\n        node.initStatusOnSubmit()\n        jobManager.resetNodeJob(node)\n\n    try:\n        res = sub.submit(nodesToProcess, edgesToProcess, graph.filepath, submitLabel=submitLabel)\n        if res:\n            if isinstance(res, BaseSubmittedJob):\n                jobManager.addJob(res, nodesToProcess)\n        else:\n            for node in nodesToProcess:\n                # TODO : Notify the node that there was an issue on submit\n                pass\n    except Exception as exc:\n        logging.error(f\"Error on submit: {exc}\")\n\n\ndef submit(graphFile, submitter, toNode=None, submitLabel=\"{projectName}\"):\n    \"\"\"\n    Submit the given graph via the given submitter.\n    \"\"\"\n    graph = loadGraph(graphFile)\n    toNodes = graph.findNodes(toNode) if toNode else None\n    submitGraph(graph, submitter, toNodes, submitLabel=submitLabel)\n"
  },
  {
    "path": "meshroom/core/graphIO.py",
    "content": "from enum import Enum\nfrom typing import Any, TYPE_CHECKING, Union\n\nimport meshroom\nfrom meshroom.core import Version\nfrom meshroom.core.attribute import Attribute, GroupAttribute, ListAttribute\nfrom meshroom.core.node import Node\n\nif TYPE_CHECKING:\n    from meshroom.core.graph import Graph\n\n\nclass GraphIO:\n    \"\"\"Centralize Graph file keys and IO version.\"\"\"\n\n    __version__ = \"2.0\"\n\n    class Keys:\n        \"\"\"File Keys.\"\"\"\n\n        # Doesn't inherit enum to simplify usage (GraphIO.Keys.XX, without .value)\n        Header = \"header\"\n        NodesVersions = \"nodesVersions\"\n        ReleaseVersion = \"releaseVersion\"\n        FileVersion = \"fileVersion\"\n        Graph = \"graph\"\n        Template = \"template\"\n\n    class Features(Enum):\n        \"\"\"File Features.\"\"\"\n\n        Graph = \"graph\"\n        Header = \"header\"\n        NodesVersions = \"nodesVersions\"\n        PrecomputedOutputs = \"precomputedOutputs\"\n        NodesPositions = \"nodesPositions\"\n\n    @staticmethod\n    def getFeaturesForVersion(fileVersion: Union[str, Version]) -> tuple[\"GraphIO.Features\", ...]:\n        \"\"\"Return the list of supported features based on a file version.\n\n        Args:\n            fileVersion (str, Version): the file version\n\n        Returns:\n            tuple of GraphIO.Features: the list of supported features\n        \"\"\"\n        if isinstance(fileVersion, str):\n            fileVersion = Version(fileVersion)\n\n        features = [GraphIO.Features.Graph]\n        if fileVersion >= Version(\"1.0\"):\n            features += [\n                GraphIO.Features.Header,\n                GraphIO.Features.NodesVersions,\n                GraphIO.Features.PrecomputedOutputs,\n            ]\n\n        if fileVersion >= Version(\"1.1\"):\n            features += [GraphIO.Features.NodesPositions]\n\n        return tuple(features)\n\n\nclass GraphSerializer:\n    \"\"\"Standard Graph serializer.\"\"\"\n\n    def __init__(self, graph: \"Graph\") -> None:\n        self._graph = graph\n\n    def serialize(self) -> dict:\n        \"\"\"\n        Serialize the Graph.\n        \"\"\"\n        return {\n            GraphIO.Keys.Header: self.serializeHeader(),\n            GraphIO.Keys.Graph: self.serializeContent(),\n        }\n\n    @property\n    def nodes(self) -> list[Node]:\n        return self._graph.nodes\n\n    def serializeHeader(self) -> dict:\n        \"\"\"Build and return the graph serialization header.\n\n        The header contains metadata about the graph, such as the:\n            - version of the software used to create it.\n            - version of the file format.\n            - version of the nodes types used in the graph.\n            - template flag.\n        \"\"\"\n        header: dict[str, Any] = {}\n        header[GraphIO.Keys.ReleaseVersion] = meshroom.__version__\n        header[GraphIO.Keys.FileVersion] = GraphIO.__version__\n        header[GraphIO.Keys.NodesVersions] = self._getNodeTypesVersions()\n        return header\n\n    def _getNodeTypesVersions(self) -> dict[str, str]:\n        \"\"\"Get registered versions of each node types in `nodes`, excluding CompatibilityNode instances.\"\"\"\n        nodeTypes = {node.nodeDesc.__class__ for node in self.nodes if isinstance(node, Node)}\n        nodeTypesVersions = {\n            nodeType.__name__: version\n            for nodeType in nodeTypes\n            if (version := meshroom.core.nodeVersion(nodeType)) is not None\n        }\n        # Sort them by name (to avoid random order changing from one save to another).\n        return dict(sorted(nodeTypesVersions.items()))\n\n    def serializeContent(self) -> dict:\n        \"\"\"Graph content serialization logic.\"\"\"\n        return {node.name: self.serializeNode(node) for node in sorted(self.nodes, key=lambda n: n.name)}\n\n    def serializeNode(self, node: Node) -> dict:\n        \"\"\"Node serialization logic.\"\"\"\n        return node.toDict()\n\n\nclass TemplateGraphSerializer(GraphSerializer):\n    \"\"\"Serializer for serializing a graph as a template.\"\"\"\n\n    def serializeHeader(self) -> dict:\n        header = super().serializeHeader()\n        header[GraphIO.Keys.Template] = True\n        return header\n\n    def serializeNode(self, node: Node) -> dict:\n        \"\"\"Adapt node serialization to template graphs.\n\n        Instead of getting all the inputs and internal attribute keys, only get the keys of\n        the attributes whose value is not the default one.\n        The output attributes, UIDs, parallelization parameters and internal folder are\n        not relevant for templates, so they are explicitly removed from the returned dictionary.\n        \"\"\"\n        # For now, implemented as a post-process to update the default serialization.\n        nodeData = super().serializeNode(node)\n\n        inputKeys = list(nodeData[\"inputs\"].keys())\n\n        internalInputKeys = []\n        internalInputs = nodeData.get(\"internalInputs\", None)\n        if internalInputs:\n            internalInputKeys = list(internalInputs.keys())\n\n        for attrName in inputKeys:\n            attribute = node.attribute(attrName)\n            # check that attribute is not a link for choice attributes\n            if attribute.isDefault and not attribute.isLink:\n                del nodeData[\"inputs\"][attrName]\n\n        for attrName in internalInputKeys:\n            attribute = node.internalAttribute(attrName)\n            # check that internal attribute is not a link for choice attributes\n            if attribute.isDefault and not attribute.isLink:\n                del nodeData[\"internalInputs\"][attrName]\n\n        # If all the internal attributes are set to their default values, remove the entry\n        if len(nodeData[\"internalInputs\"]) == 0:\n            del nodeData[\"internalInputs\"]\n\n        del nodeData[\"outputs\"]\n        del nodeData[\"uid\"]\n        del nodeData[\"parallelization\"]\n\n        return nodeData\n\n\nclass PartialGraphSerializer(GraphSerializer):\n    \"\"\"Serializer to serialize a partial graph (a subset of nodes).\"\"\"\n\n    def __init__(self, graph: \"Graph\", nodes: list[Node]):\n        super().__init__(graph)\n        self._nodes = nodes\n\n    @property\n    def nodes(self) -> list[Node]:\n        \"\"\"Override to consider only the subset of nodes.\"\"\"\n        return self._nodes\n\n    def serializeNode(self, node: Node) -> dict:\n        \"\"\"Adapt node serialization to partial graph serialization.\"\"\"\n        # NOTE: For now, implemented as a post-process to the default serialization.\n        nodeData = super().serializeNode(node)\n\n        # Override input attributes with custom serialization logic, to handle attributes\n        # connected to nodes that are not in the list of nodes to serialize.\n        if nodeData.get(\"inputs\", None):\n            for attributeName in nodeData[\"inputs\"]:\n                nodeData[\"inputs\"][attributeName] = self._serializeAttribute(node.attribute(attributeName))\n\n        # Clear UID for non-compatibility nodes, as the custom attribute serialization\n        # can be impacting the UID by removing connections to missing nodes.\n        if not node.isCompatibilityNode:\n            del nodeData[\"uid\"]\n\n        return nodeData\n\n    def _serializeAttribute(self, attribute: Attribute) -> Any:\n        \"\"\"\n        Serialize `attribute` (recursively for list/groups) and deal with attributes being connected\n        to nodes that are not part of the partial list of nodes to serialize.\n        \"\"\"\n        linkAttribute = attribute.inputLink\n\n        if linkAttribute is not None:\n            # Use standard link serialization if upstream node is part of the serialization.\n            if linkAttribute.node in self.nodes:\n                return attribute.getSerializedValue()\n            # Skip link serialization otherwise.\n            # If part of a list, this entry can be discarded.\n            if isinstance(attribute.root, ListAttribute):\n                return None\n            # Otherwise, return the default value for this attribute.\n            return attribute.getDefaultValue()\n\n        if isinstance(attribute, ListAttribute):\n            # Recusively serialize each child of the ListAttribute, skipping those for which the attribute\n            # serialization logic above returns None.\n            return [\n                exportValue\n                for child in attribute\n                if (exportValue := self._serializeAttribute(child)) is not None\n            ]\n\n        if isinstance(attribute, GroupAttribute):\n            # Recursively serialize each child of the group attribute.\n            return {name: self._serializeAttribute(child) for name, child in attribute.value.items()}\n\n        return attribute.getSerializedValue()\n"
  },
  {
    "path": "meshroom/core/keyValues.py",
    "content": "import json\nfrom typing import Any\n\nfrom meshroom.common import BaseObject, Property, Variant, Signal, DictModel, Slot\nfrom meshroom.core import desc, hashValue\n\nclass KeyValues(BaseObject):\n    \"\"\"\n    Used to store a list of pairs (key, value) based on an attribute description.\n    \"\"\"\n\n    class KeyValuePair(BaseObject):\n        \"\"\"\n        Pair of (key, value), this object cannot be modified.\n        \"\"\"\n        def __init__(self, key: int, value: Any, parent=None):\n            super().__init__(parent)\n            self._key = key\n            self._value = value\n\n        key = Property(int, lambda self: self._key, constant=True)\n        value = Property(Variant, lambda self: self._value, constant=True)\n\n    def __init__(self, desc: desc.Attribute, parent=None):\n        \"\"\"\n        KeyValues constructor\n        Args:\n            description: The corresponding Attribute description.\n            parent: (optional) The parent BaseObject if any.\n        \"\"\"\n        super().__init__(parent)\n        self._desc = desc\n        self._pairs = DictModel(keyAttrName=\"key\", parent=self)\n        # TODO: Add interpolation. For now no interpolation.\n\n    def reset(self):\n        \"\"\"\n        Clear the list of pairs.\n        \"\"\"\n        self._pairs.clear()\n        self.pairsChanged.emit()\n\n    def resetFromDict(self, pairs: dict):\n        \"\"\"\n        Reset the list of pairs from a given dict.\n        \"\"\"\n        self._pairs.clear()\n        for k, v in pairs.items():\n            self._pairs.add(KeyValues.KeyValuePair(int(k), self._desc.validateValue(v), self))\n        self.pairsChanged.emit()\n\n    def add(self, key: str, value: Any):\n        \"\"\"\n        Add a new pair (key, value) to the list of pairs from a given key and value.\n        \"\"\"\n        # Avoid negative key\n        if int(key) < 0:\n            return\n        # Get existing pair with the given key (or None)\n        pair = self._pairs.get(int(key))\n        # Remove existing pair\n        if pair is not None:\n            self._pairs.remove(pair)\n        # Add new pair\n        self._pairs.add(KeyValues.KeyValuePair(int(key), self._desc.validateValue(value), self))\n        self.pairsChanged.emit()\n\n    def remove(self, key: str):\n        \"\"\"\n        Remove a pair (key, value) of the list of pairs from a given key.\n        \"\"\"\n        # Get existing pair with the given key (or None)\n        pair = self._pairs.get(int(key))\n        # Remove existing pair\n        if pair is not None:\n            self._pairs.remove(pair)\n            self.pairsChanged.emit()\n\n    def getSerializedValues(self) -> Any:\n        \"\"\"\n        Return the list of pairs serialized.\n        \"\"\"\n        return { str(pair.key): pair.value for pair in self._pairs }\n\n    def getKeys(self) -> list:\n        \"\"\"\n        Return the list of keys.\n        \"\"\"\n        return [ str(pair.key) for pair in self._pairs ]\n\n    def getJson(self) -> str:\n        \"\"\"\n        Return the list of pairs formatted as a JSON string.\n        \"\"\"\n        return json.dumps(self.getSerializedValues())\n\n    def uid(self) -> str:\n        \"\"\"\n        Compute the UID from the list of pairs.\n        \"\"\"\n        uids = []\n        for pair in sorted(self._pairs, key=lambda pair: pair.key):\n            uids.extend([pair.key, pair.value])\n        return hashValue(uids)\n\n    @Slot(str, result=bool)\n    def hasKey(self, key: str) -> bool:\n        \"\"\"\n        Whether this given key exists in the list of pairs.\n        \"\"\"\n        return self._pairs.get(int(key)) is not None\n\n    @Slot(str, result=Variant)\n    def getValueAtKeyOrDefault(self, key: str) -> Any:\n        \"\"\"\n        Return the value or the default value from a given key.\n        \"\"\"\n        # Get existing pair with the given key (or None)\n        pair = self._pairs.get(int(key))\n        # Return pair value\n        if pair is not None:\n            return pair.value\n        # Return default value\n        return self._desc.value\n\n    # Emitted when something changed in the list of pairs.\n    pairsChanged = Signal()\n    # The list of pairs (key, value).\n    pairs = Property(Variant, lambda self: self._pairs, notify=pairsChanged)\n    # The type of key used (viewId, poseId, ...).\n    keyType = Property(str, lambda self: self._desc.keyType, constant=True)\n"
  },
  {
    "path": "meshroom/core/mtyping.py",
    "content": "\"\"\"\nCommon typing aliases used in Meshroom.\n\"\"\"\n\nfrom pathlib import Path\nfrom typing import Union\n\nPathLike = Union[Path, str]\n"
  },
  {
    "path": "meshroom/core/node.py",
    "content": "#!/usr/bin/env python\nimport sys\nimport atexit\nimport copy\nimport datetime\nimport json\nimport logging\nimport os\nimport platform\nimport re\nimport shutil\nimport time\nimport uuid\nfrom collections import namedtuple, OrderedDict\nfrom enum import Enum, auto\nfrom typing import Callable, Optional, List\n\nimport meshroom\nfrom meshroom.common import Signal, Variant, Property, BaseObject, Slot, ListModel, DictModel\nfrom meshroom.core import desc, plugins, stats, hashValue, nodeVersion, Version, MrNodeType\nfrom meshroom.core.attribute import attributeFactory, ListAttribute, GroupAttribute, Attribute\nfrom meshroom.core.exception import NodeUpgradeError, UnknownNodeTypeError\nfrom meshroom.core.mtyping import PathLike\n\n\ndef getWritingFilepath(filepath: str) -> str:\n    return filepath + '.writing.' + str(uuid.uuid4())\n\n\ndef renameWritingToFinalPath(writingFilepath: str, filepath: str) -> str:\n    if platform.system() == 'Windows':\n        # On Windows, attempting to remove a file that is in use causes an exception to be raised.\n        # So we may need multiple trials, if someone is reading it at the same time.\n        for _ in range(20):\n            try:\n                os.remove(filepath)\n                # If remove is successful, we can stop the iterations\n                break\n            except OSError:\n                pass\n    os.rename(writingFilepath, filepath)\n\nclass Status(Enum):\n    \"\"\"\n    \"\"\"\n    NONE = 0\n    SUBMITTED = 1\n    RUNNING = 2\n    ERROR = 3\n    STOPPED = 4\n    KILLED = 5\n    SUCCESS = 6\n    INPUT = 7  # Special status for input nodes\n\n\nclass ExecMode(Enum):\n    \"\"\"\n    \"\"\"\n    NONE = auto()\n    LOCAL = auto()\n    EXTERN = auto()\n\n\n# Simple structure for storing chunk information\nNodeChunkSetup = namedtuple(\"NodeChunks\", [\"blockSize\", \"fullSize\", \"nbBlocks\"])\n\nclass NodeStatusData(BaseObject):\n    __slots__ = (\"nodeName\", \"nodeType\", \"status\", \"execMode\", \"packageName\", \"mrNodeType\",\n                 \"submitterSessionUid\", \"chunksBlockSize\", \"chunksFullSize\", \"chunksNbBlocks\", \"jobInfo\")\n\n    def __init__(self, nodeName='', nodeType='', packageName='',\n                 mrNodeType: MrNodeType = MrNodeType.NONE, parent: BaseObject = None):\n        super().__init__(parent)\n        self.nodeName: str = nodeName\n        self.nodeType: str = nodeType\n        self.packageName: str = packageName\n        self.mrNodeType: str = mrNodeType\n\n        # Session UID where the node was submitted\n        self.submitterSessionUid: Optional[str] = None\n\n        self.reset()\n\n    def reset(self):\n        self.resetChunkInfo()\n        self.resetDynamicValues()\n\n    def resetChunkInfo(self):\n        self.chunks: NodeChunkSetup = None\n\n    def resetDynamicValues(self):\n        self.status: Status = Status.NONE\n        self.execMode: ExecMode = ExecMode.NONE\n        self.jobInfo: dict = {}\n\n    def setNodeType(self, node):\n        \"\"\"\n        Set the node type and package information from the given node.\n        We do not set the name in this method as it may vary if there are duplicates.\n        \"\"\"\n        self.nodeType = node.nodeType\n        self.packageName = node.packageName\n        self.mrNodeType = node.getMrNodeType()\n\n    def setNode(self, node):\n        \"\"\" Set the node information from one node instance. \"\"\"\n        self.nodeName = node.name\n        self.setNodeType(node)\n\n    def setJob(self, jid, submitterName):\n        \"\"\" Set Job information on the node. \"\"\"\n        self.jobInfo = {\n            \"jid\": str(jid),\n            \"submitterName\": str(submitterName),\n        }\n\n    @property\n    def jobName(self):\n        if self.jobInfo:\n            return f\"{self.jobInfo['submitterName']}<{self.jobInfo['jid']}>\"\n        else:\n            return \"UNKNOWN\"\n\n    def initExternSubmit(self):\n        \"\"\"\n        When submitting a node, we reset the status information to ensure that we do not keep\n        outdated information.\n        \"\"\"\n        self.resetDynamicValues()\n        self.submitterSessionUid = meshroom.core.sessionUid\n        self.status = Status.SUBMITTED\n        self.execMode = ExecMode.EXTERN\n\n    def initLocalSubmit(self):\n        \"\"\"\n        When submitting a node, we reset the status information to ensure that we do not keep\n        outdated information.\n        \"\"\"\n        self.resetDynamicValues()\n        self.submitterSessionUid = meshroom.core.sessionUid\n        self.status = Status.SUBMITTED\n        self.execMode = ExecMode.LOCAL\n\n    def toDict(self):\n        keys = list(self.__slots__) or []\n        d = {key:getattr(self, key, 0) for key in keys}\n        for _k, _v in d.items():\n            if isinstance(_v, Enum):\n                d[_k] = _v.name\n        if self.chunks:\n            d[\"chunksBlockSize\"] = self.chunks.blockSize\n            d[\"chunksFullSize\"] = self.chunks.fullSize\n            d[\"chunksNbBlocks\"] = self.chunks.nbBlocks\n        return d\n\n    def fromDict(self, d):\n        self.reset()\n        if \"mrNodeType\" in d:\n            self.mrNodeType = MrNodeType[d.pop(\"mrNodeType\")]\n        if \"chunksBlockSize\" in d and \"chunksFullSize\" in d and \"chunksNbBlocks\" in d:\n            blockSize = int(d.pop(\"chunksBlockSize\") or 0)\n            fullSize = int(d.pop(\"chunksFullSize\") or 0)\n            nbBlocks = int(d.pop(\"chunksNbBlocks\") or 0)\n            self.chunks = NodeChunkSetup(blockSize, fullSize, nbBlocks)\n        if \"status\" in d:\n            self.status: Status = Status[d.pop(\"status\")]\n        if \"execMode\" in d:\n            self.execMode = ExecMode[d.pop(\"execMode\")]\n        for _key, _value in d.items():\n            if _key in self.__slots__:\n                setattr(self, _key, _value)\n\n    def loadFromCache(self, statusFile):\n        self.reset()\n        try:\n            with open(statusFile) as jsonFile:\n                statusData = json.load(jsonFile)\n            self.fromDict(statusData)\n        except Exception as e:\n            logging.warning(f\"(loadFromCache) {self.nodeName}: Error while loading status file {statusFile}: {e}\")\n            self.reset()\n\n    @property\n    def nbChunks(self):\n        nbBlocks = self.chunks.nbBlocks if self.chunks else -1\n        return nbBlocks\n\n    @property\n    def fullSize(self):\n        fullSize = self.chunks.fullSize if self.chunks else -1\n        return fullSize\n\n    def getChunkRanges(self):\n        if not self.chunks:\n            return []\n        ranges = []\n        for i in range(self.chunks.nbBlocks):\n            ranges.append(desc.Range(\n                iteration=i,\n                blockSize=self.chunks.blockSize,\n                fullSize=self.chunks.fullSize,\n                nbBlocks=self.chunks.nbBlocks\n            ))\n        return ranges\n\n    def setChunks(self, chunks):\n        blockSize, fullSize, nbBlocks = 1, 1, 1\n        for c in chunks:\n            r = c.range\n            blockSize, fullSize, nbBlocks = r.blockSize, r.fullSize, r.nbBlocks\n            break\n        self.chunks = NodeChunkSetup(blockSize, fullSize, nbBlocks)\n\n\nclass ChunkStatusData(BaseObject):\n    \"\"\"\n    \"\"\"\n    dateTimeFormatting = '%Y-%m-%d %H:%M:%S.%f'\n\n    __slots__ = (\n        \"nodeName\", \"mrNodeType\", \"computeSessionUid\", \"execMode\", \"status\",\n        \"commandLine\", \"startDateTime\", \"endDateTime\", \"elapsedTime\", \"hostname\"\n    )\n\n    def __init__(self, nodeName='', mrNodeType: MrNodeType = MrNodeType.NONE, parent: BaseObject = None):\n        super().__init__(parent)\n\n        self.nodeName: str = nodeName\n        self.mrNodeType = mrNodeType\n\n        self.computeSessionUid: Optional[str] = None    # Session where computation is done\n\n        self.execMode: ExecMode = ExecMode.NONE\n\n        self.resetDynamicValues()\n\n    def resetDynamicValues(self):\n        self.status: Status = Status.NONE\n        self.commandLine: str = \"\"\n        self._startTime: Optional[datetime.datetime] = None\n        self.startDateTime: str = \"\"\n        self.endDateTime: str = \"\"\n        self.elapsedTime: float = 0.0\n        self.hostname: str = \"\"\n\n    def setNode(self, node):\n        \"\"\" Set the node information from one node instance. \"\"\"\n        self.nodeName = node.name\n        self.mrNodeType = node.getMrNodeType()\n\n    def merge(self, other):\n        self.startDateTime = min(self.startDateTime, other.startDateTime)\n        self.endDateTime = max(self.endDateTime, other.endDateTime)\n        self.elapsedTime += other.elapsedTime\n\n    def reset(self):\n        self.nodeName: str = \"\"\n        self.mrNodeType: MrNodeType = MrNodeType.NONE\n        self.execMode: ExecMode = ExecMode.NONE\n        self.resetDynamicValues()\n\n    def initStartCompute(self):\n        import platform\n        self.computeSessionUid = meshroom.core.sessionUid\n        self.hostname = platform.node()\n        self._startTime = time.time()\n        self.startDateTime = datetime.datetime.now().strftime(self.dateTimeFormatting)\n        # to get datetime obj: datetime.datetime.strptime(obj, self.dateTimeFormatting)\n        self.status = Status.RUNNING\n        # Note: We do not modify the \"execMode\" here, as it is set in the init*Submit methods.\n        #       When we compute (from renderfarm or isolated environment),\n        #       we do not want to modify the execMode set from the submit.\n\n    def initIsolatedCompute(self):\n        \"\"\"\n        When submitting a node, we reset the status information to ensure that we do not keep\n        outdated information.\n        \"\"\"\n        self.resetDynamicValues()\n        self.initStartCompute()\n        assert self.mrNodeType == MrNodeType.NODE\n        self.computeSessionUid = None\n\n    def initExternSubmit(self):\n        \"\"\"\n        When submitting a node, we reset the status information to ensure that we do not keep\n        outdated information.\n        \"\"\"\n        self.resetDynamicValues()\n        self.computeSessionUid = None\n        self.status = Status.SUBMITTED\n        self.execMode = ExecMode.EXTERN\n\n    def initLocalSubmit(self):\n        \"\"\"\n        When submitting a node, we reset the status information to ensure that we do not keep\n        outdated information.\n        \"\"\"\n        self.resetDynamicValues()\n        self.computeSessionUid = None\n        self.status = Status.SUBMITTED\n        self.execMode = ExecMode.LOCAL\n\n    def initEndCompute(self):\n        self.computeSessionUid = meshroom.core.sessionUid\n        self.endDateTime = datetime.datetime.now().strftime(self.dateTimeFormatting)\n        if self._startTime != None:\n            self.elapsedTime = time.time() - self._startTime\n\n    @property\n    def elapsedTimeStr(self):\n        return str(datetime.timedelta(seconds=self.elapsedTime))\n\n    def toDict(self):\n        keys = list(self.__slots__) or []\n        d = {key:getattr(self, key) for key in keys}\n        for _k, _v in d.items():\n            if isinstance(_v, Enum):\n                d[_k] = _v.name\n        return d\n\n    def fromDict(self, d):\n        self.reset()\n        if \"status\" in d:\n            self.status: Status = Status[d.pop(\"status\")]\n        if \"execMode\" in d:\n            self.execMode = ExecMode[d.pop(\"execMode\")]\n        if \"mrNodeType\" in d:\n            self.mrNodeType = MrNodeType[d.pop(\"mrNodeType\")]\n        for _key, _value in d.items():\n            if _key in self.__slots__:\n                setattr(self, _key, _value)\n\n\nclass LogManager:\n    dateTimeFormatting = '%H:%M:%S'\n\n    def __init__(self, logger, logFile):\n        self.logger: logging.Logger = logger\n        self.logFile: PathLike = logFile\n        self._previousHandlers: List[logging.Handler] = []\n        self._previousLevel: int = 0\n\n    class Formatter(logging.Formatter):\n        def format(self, record):\n            # Make level name lower case\n            record.levelname = record.levelname.lower()\n            return logging.Formatter.format(self, record)\n\n    def configureLogger(self):\n        self._previousLevel = self.logger.level\n        self._previousHandlers = []\n        for handler in self.logger.handlers[:]:\n            self._previousHandlers.append(handler)\n            self.logger.removeHandler(handler)\n        handler = logging.FileHandler(self.logFile)\n        formatter = self.Formatter('[%(asctime)s.%(msecs)03d][%(levelname)s] %(message)s',\n                                   self.dateTimeFormatting)\n        handler.setFormatter(formatter)\n        self.logger.addHandler(handler)\n\n    def restorePreviousLogger(self):\n        for h in self.logger.handlers[:]:\n            self.logger.removeHandler(h)\n        for h in self._previousHandlers:\n            self.logger.addHandler(h)\n        self.logger.setLevel(self._previousLevel)\n\n    def clearLogFile(self):\n        open(self.logFile, 'w').close()\n\n    def start(self, level):\n        # Make sure the log file exists\n        if not os.path.exists(self.logFile):\n            self.clearLogFile()\n        self.configureLogger()\n        self.logger.propagate = False\n        self.logger.setLevel(self.textToLevel(level))\n        self.progressBar = False\n\n    def end(self):\n        for handler in self.logger.handlers[:]:\n            # Stops the file being locked\n            handler.close()\n\n    def makeProgressBar(self, end, message=''):\n        assert end > 0\n        assert not self.progressBar\n\n        self.progressEnd = end\n        self.currentProgressTics = 0\n        self.progressBar = True\n\n        with open(self.logFile, 'a') as f:\n            if message:\n                f.write(message+'\\n')\n            f.write('0%   10   20   30   40   50   60   70   80   90   100%\\n')\n            f.write('|----|----|----|----|----|----|----|----|----|----|\\n\\n')\n\n            f.close()\n\n        with open(self.logFile) as f:\n            content = f.read()\n            self.progressBarPosition = content.rfind('\\n')\n\n            f.close()\n\n    def updateProgressBar(self, value):\n        assert self.progressBar\n        assert value <= self.progressEnd\n\n        tics = round((value/self.progressEnd)*51)\n\n        with open(self.logFile, 'r+') as f:\n            text = f.read()\n            for i in range(tics-self.currentProgressTics):\n                text = text[:self.progressBarPosition]+'*'+text[self.progressBarPosition:]\n            f.seek(0)\n            f.write(text)\n            f.close()\n\n        self.currentProgressTics = tics\n\n    def completeProgressBar(self):\n        assert self.progressBar\n\n        self.progressBar = False\n\n    @staticmethod\n    def textToLevel(text):\n        text = text.lower()\n        if text in [\"critical\", \"fatal\"]:\n            return logging.CRITICAL\n        elif text == \"error\":\n            return logging.ERROR\n        elif text == \"warning\":\n            return logging.WARNING\n        elif text == \"info\":\n            return logging.INFO\n        elif text == \"debug\":\n            return logging.DEBUG\n        elif text == \"trace\":\n            return logging.TRACE\n        else:\n            return logging.NOTSET\n\n\nrunningProcesses: dict[str, \"NodeChunk\"] = {}\n\n\n@atexit.register\ndef clearProcessesStatus():\n    for k, v in runningProcesses.items():\n        v.upgradeStatusTo(Status.KILLED)\n\n\nclass NodeChunk(BaseObject):\n    def __init__(self, node, range, parent=None):\n        super().__init__(parent)\n        self.node = node\n        self.range = range\n        self._logManager = None\n        self._status: ChunkStatusData = ChunkStatusData(nodeName=node.name, mrNodeType=node.getMrNodeType())\n        self.statistics: stats.Statistics = stats.Statistics()\n        self.statusFileLastModTime = -1\n        self.subprocess = None\n        # Notify update in filepaths when node's internal folder changes\n        self.node.internalFolderChanged.connect(self.nodeFolderChanged)\n\n    def __repr__(self):\n        return f\"<NodeChunk {hex(id(self))}>\"\n\n    @property\n    def index(self):\n        return self.range.iteration\n\n    @property\n    def name(self):\n        if self.range.blockSize:\n            return f\"{self.node.name}({self.index})\"\n        else:\n            return self.node.name\n\n    @property\n    def logManager(self):\n        if self._logManager is None:\n            logger = logging.getLogger(self.node.getName())\n            self._logManager = LogManager(logger, self.getLogFile())\n        return self._logManager\n\n    def getStatusName(self):\n        return self._status.status.name\n\n    @property\n    def logger(self):\n        return self.logManager.logger\n\n    def getExecModeName(self):\n        return self._status.execMode.name\n\n    def shouldMonitorChanges(self):\n        \"\"\"\n        Check whether we should monitor changes in minimal mode.\n        Only chunks that are run externally or local_isolated should be monitored,\n        when run locally, status changes are already notified.\n        Chunks with an ERROR status may be re-submitted externally and should thus still be\n        monitored.\n        \"\"\"\n        return (self.isExtern() and self._status.status in (Status.SUBMITTED, Status.RUNNING, Status.ERROR)) or \\\n               (self.node.getMrNodeType() == MrNodeType.NODE and self._status.status in (Status.SUBMITTED, Status.RUNNING))\n\n    def updateStatusFromCache(self):\n        \"\"\"\n        Update chunk status based on status file content/existence.\n        \"\"\"\n        # TODO : If this is a placeholder chunk\n        # Then we should not do anything here\n\n        statusFile = self.getStatusFile()\n        oldStatus = self._status.status\n        # No status file => reset status to Status.None\n        if not os.path.exists(statusFile):\n            self.statusFileLastModTime = -1\n            self._status.reset()\n            self._status.setNode(self.node)\n        else:\n            try:\n                with open(statusFile) as jsonFile:\n                    statusData = json.load(jsonFile)\n                # logging.debug(f\"updateStatusFromCache({self.node.name}): From status {self._status.status} to {statusData['status']}\")\n                self._status.fromDict(statusData)\n                self.statusFileLastModTime = os.path.getmtime(statusFile)\n            except Exception as exc:\n                logging.debug(f\"updateStatusFromCache({self.node.name}): Error while loading status file {statusFile}: {exc}\")\n                self.statusFileLastModTime = -1\n                self._status.reset()\n                self._status.setNode(self.node)\n\n        if oldStatus != self._status.status:\n            self.statusChanged.emit()\n\n    def _getFile(self, fileType: str):\n        \"\"\"\n        Return the path for the requested type of file.\n        It is expected to be prefixed by the chunk number, but for compatibility purposes, it may not be.\n        \"\"\"\n        chunkIndex = self.index if self.range.blockSize else 0\n        # Retro-compatibility: ensure we do not lose files computed when single chunks were not prefixed\n        # If both the prefixed and not prefixed files exist, the prefixed one should be returned\n        if os.path.exists(os.path.join(self.node.internalFolder, fileType)):\n            if not os.path.exists(os.path.join(self.node.internalFolder, str(chunkIndex) + \".\" + fileType)):\n                return os.path.join(self.node.internalFolder, fileType)\n        return os.path.join(self.node.internalFolder, str(chunkIndex) + \".\" + fileType)\n\n    def getStatusFile(self):\n        return self._getFile(\"status\")\n\n    def getStatisticsFile(self):\n        return self._getFile(\"statistics\")\n\n    def getLogFile(self):\n        return self._getFile(\"log\")\n\n    def saveStatusFile(self):\n        \"\"\"\n        Write node status on disk.\n        \"\"\"\n        data = self._status.toDict()\n        statusFilepath = self.getStatusFile()\n        folder = os.path.dirname(statusFilepath)\n        os.makedirs(folder, exist_ok=True)\n\n        statusFilepathWriting = getWritingFilepath(statusFilepath)\n        with open(statusFilepathWriting, 'w') as jsonFile:\n            json.dump(data, jsonFile, indent=4)\n        renameWritingToFinalPath(statusFilepathWriting, statusFilepath)\n\n    def upgradeStatusFile(self):\n        \"\"\"\n        Upgrade node status file based on the current status.\n        \"\"\"\n        self.saveStatusFile()\n        # We want to make sure the nodeStatus is up to date too\n        self.node.upgradeStatusFile()\n        self.statusChanged.emit()\n\n    def upgradeStatusTo(self, newStatus, execMode=None):\n        if newStatus.value < self._status.status.value:\n            logging.warning(f\"Downgrade status on node '{self.name}' from {self._status.status} to {newStatus}\")\n\n        if execMode is not None:\n            self._status.execMode = execMode\n        self._status.status = newStatus\n        self.upgradeStatusFile()\n\n    def updateStatisticsFromCache(self):\n        \"\"\"\n        \"\"\"\n        oldTimes = self.statistics.times\n        statisticsFile = self.getStatisticsFile()\n        if not os.path.exists(statisticsFile):\n            return\n        with open(statisticsFile) as jsonFile:\n            statisticsData = json.load(jsonFile)\n        self.statistics.fromDict(statisticsData)\n        if oldTimes != self.statistics.times:\n            self.statisticsChanged.emit()\n\n    def saveStatistics(self):\n        data = self.statistics.toDict()\n        statisticsFilepath = self.getStatisticsFile()\n        folder = os.path.dirname(statisticsFilepath)\n        os.makedirs(folder, exist_ok=True)\n        statisticsFilepathWriting = getWritingFilepath(statisticsFilepath)\n        with open(statisticsFilepathWriting, 'w') as jsonFile:\n            json.dump(data, jsonFile, indent=4)\n        renameWritingToFinalPath(statisticsFilepathWriting, statisticsFilepath)\n\n    def isAlreadySubmitted(self):\n        return self._status.status in (Status.SUBMITTED, Status.RUNNING)\n\n    def isAlreadySubmittedOrFinished(self):\n        return self._status.status in (Status.SUBMITTED, Status.RUNNING, Status.SUCCESS)\n\n    def isFinishedOrRunning(self):\n        return self._status.status in (Status.SUCCESS, Status.RUNNING)\n\n    def isRunning(self):\n        return self._status.status == Status.RUNNING\n\n    def isStopped(self):\n        return self._status.status == Status.STOPPED\n\n    def isFinished(self):\n        return self._status.status == Status.SUCCESS\n\n    def process(self, forceCompute=False, inCurrentEnv=False):\n        if not forceCompute and self._status.status == Status.SUCCESS:\n            logging.info(f\"Node chunk already computed: {self.name}\")\n            return\n\n        # Start the process environment for nodes running in isolation.\n        # This only happens once, when the node has the SUBMITTED status.\n        # The sub-process will go through this method again, but the node status will\n        # have been set to RUNNING.\n        if not inCurrentEnv and self.node.getMrNodeType() == MrNodeType.NODE:\n            self._processInIsolatedEnvironment()\n            return\n\n        runningProcesses[self.name] = self\n        self._status.setNode(self.node)\n        self._status.initStartCompute()\n        self.upgradeStatusFile()\n        executionStatus = None\n        self.statThread = stats.StatisticsThread(self)\n        self.statThread.start()\n\n        try:\n            logging.info(f\"[Process chunk] Start processing...\")\n            self.node.nodeDesc.processChunk(self)\n            # NOTE: this assumes saving the output attributes for each chunk\n            self.node.saveOutputAttr()\n            executionStatus = Status.SUCCESS\n        except Exception:\n            self.updateStatusFromCache()  # check if the status has been updated by another process\n            if self._status.status != Status.STOPPED:\n                executionStatus = Status.ERROR\n            raise\n        except (KeyboardInterrupt, SystemError, GeneratorExit):\n            executionStatus = Status.STOPPED\n            raise\n        finally:\n            self._status.setNode(self.node)\n            self._status.initEndCompute()\n            self.upgradeStatusFile()\n\n            if executionStatus:\n                self.upgradeStatusTo(executionStatus)\n            logging.info(f\"[Process chunk] elapsed time: {self._status.elapsedTimeStr}\")\n            # Ask and wait for the stats thread to stop\n            self.statThread.stopRequest()\n            self.statThread.join()\n            self.statistics = stats.Statistics()\n            del runningProcesses[self.name]\n\n\n    def _processInIsolatedEnvironment(self):\n        \"\"\"\n        Process this node chunk in the isolated environment defined in the environment\n        configuration.\n        \"\"\"\n        try:\n            self._status.setNode(self.node)\n            self._status.initIsolatedCompute()\n            self.upgradeStatusFile()\n\n            self.node.nodeDesc.processChunkInEnvironment(self)\n        except Exception:\n            # status should be already updated by meshroom_compute\n            self.updateStatusFromCache()\n            if self._status.status not in (Status.ERROR, Status.STOPPED, Status.KILLED):\n                # If meshroom_compute has crashed or been killed, the status may have not been\n                # set to ERROR.\n                # In this particular case, we enforce it from here.\n                self.upgradeStatusTo(Status.ERROR)\n            raise\n        # Update the chunk status.\n        self.updateStatusFromCache()\n        # Update the output attributes, as any chunk may have modified them.\n        self.node.updateOutputAttr()\n\n    def stopProcess(self):\n        # Ensure that we are up-to-date\n        self.updateStatusFromCache()\n\n        if self._status.status != Status.RUNNING:\n            # When we stop the process of a node with multiple chunks, the Node function will call\n            # the stop function of each chunk.\n            # So, the chunk status could be SUBMITTED, RUNNING or ERROR.\n\n            if self._status.status is Status.SUBMITTED:\n                self.upgradeStatusTo(Status.NONE)\n            elif self._status.status in (Status.ERROR, Status.STOPPED, Status.KILLED,\n                                         Status.SUCCESS, Status.NONE):\n                # Nothing to do, the computation is already stopped.\n                pass\n            else:\n                logging.debug(f\"Cannot stop process: node is not running (status is: {self._status.status}).\")\n            return\n\n        self.node.nodeDesc.stopProcess(self)\n\n        # Update the status to get latest information before changing it\n        self.updateStatusFromCache()\n        self.upgradeStatusTo(Status.STOPPED)\n\n    def isExtern(self):\n        \"\"\"\n        The computation is managed externally by another instance of Meshroom.\n        In the ambiguous case of an isolated environment, it is considered as local as we can stop\n        it (if it is run from the current Meshroom instance).\n        \"\"\"\n        if self._status.execMode == ExecMode.EXTERN:\n            return True\n        elif self._status.execMode == ExecMode.LOCAL:\n            if self._status.status in (Status.SUBMITTED, Status.RUNNING):\n                return meshroom.core.sessionUid not in (self.node._nodeStatus.submitterSessionUid, self._status.computeSessionUid)\n            return False\n        return False\n\n    statusChanged = Signal()\n    status = Property(Variant, lambda self: self._status, notify=statusChanged)\n    statusName = Property(str, getStatusName, notify=statusChanged)\n    execModeName = Property(str, getExecModeName, notify=statusChanged)\n    statisticsChanged = Signal()\n\n    nodeFolderChanged = Signal()\n    statusFile = Property(str, getStatusFile, notify=nodeFolderChanged)\n    logFile = Property(str, getLogFile, notify=nodeFolderChanged)\n    statisticsFile = Property(str, getStatisticsFile, notify=nodeFolderChanged)\n\n    nodeName = Property(str, lambda self: self.node.name, constant=True)\n    statusNodeName = Property(str, lambda self: self._status.nodeName, notify=statusChanged)\n\n    elapsedTime = Property(float, lambda self: self._status.elapsedTime, notify=statusChanged)\n\n\n# Simple structure for storing node position\nPosition = namedtuple(\"Position\", [\"x\", \"y\"])\n# Initialize default coordinates values to 0\nPosition.__new__.__defaults__ = (0,) * len(Position._fields)\n\n\nclass BaseNode(BaseObject):\n    \"\"\"\n    Base Abstract class for Graph nodes.\n    \"\"\"\n\n    # Regexp handling complex attribute names with recursive understanding of Lists and Groups\n    # i.e: a.b, a[0], a[0].b.c[1]\n    attributeRE = re.compile(r'\\.?(?P<name>\\w+)(?:\\[(?P<index>\\d+)\\])?')\n\n    def __init__(self, nodeType: str, position: Position = None, parent: BaseObject = None,\n                 uid: str = None, **kwargs):\n        \"\"\"\n        Create a new Node instance based on the given node description.\n        Any other keyword argument will be used to initialize this node's attributes.\n\n        Args:\n            nodeType: name of the node type\n            parent: this Node's parent\n            **kwargs: attributes values\n        \"\"\"\n        super().__init__(parent)\n        self._nodeType: str = nodeType\n        self.nodeDesc: desc.BaseNode = None\n        self.nodePlugin: plugins.Plugin = None\n\n        # instantiate node description if nodeType is valid\n        if meshroom.core.pluginManager.getRegisteredNodePlugin(nodeType):\n            self.nodeDesc = meshroom.core.pluginManager.getRegisteredNodePlugin(nodeType).nodeDescriptor()\n            self.nodePlugin = meshroom.core.pluginManager.getRegisteredNodePlugin(nodeType)\n\n        self.packageName: str = \"\"\n        self._internalFolder: str = \"\"\n        self._sourceCodeFolder: str = self.nodeDesc.sourceCodeFolder if self.nodeDesc else \"\"\n        self._internalFolderExp = \"{cache}/{nodeType}/{uid}\"\n\n        # temporary unique name for this node\n        self._name: str = f\"_{nodeType}_{uuid.uuid1()}\"\n        self.graph = None\n        self.dirty: bool = True  # whether this node's outputs must be re-evaluated on next Graph update\n        self._chunks: list[NodeChunk] = ListModel(parent=self)\n        self._chunksCreated = False  # Only initialize chunks on compute\n        self._chunkPlaceholder: list[NodeChunk] = ListModel(parent=self)  # Placeholder chunk for nodes with dynamic ones\n        self._uid: str = uid\n        self._expVars: dict = {}\n        self._size: int = 0\n        self._logManager: Optional[LogManager] = None\n        self._position: Position = position or Position()\n        self._attributes = DictModel(keyAttrName='name', parent=self)\n        self._internalAttributes = DictModel(keyAttrName='name', parent=self)\n        self.invalidatingAttributes: set = set()\n        self._alive: bool = True  # for QML side to know if the node can be used or is going to be deleted\n        self._locked: bool = False\n        self._duplicates = ListModel(parent=self)  # list of nodes with the same uid\n        self._hasDuplicates: bool = False\n\n        self._nodeStatus: NodeStatusData = NodeStatusData(self._name, nodeType, self.packageName,\n                                                          self.getMrNodeType())\n        self.nodeStatusFileLastModTime = -1\n\n        self.globalStatusChanged.connect(self.updateDuplicatesStatusAndLocked)\n\n        self._staticExpVars = {\n            \"nodeType\": self.nodeType,\n            \"nodeSourceCodeFolder\": self.sourceCodeFolder\n        }\n\n    def __getattr__(self, k):\n        try:\n            # Throws exception if not in prototype chain\n            return object.__getattribute__(self, k)\n        except AttributeError as err:\n            try:\n                return self.attribute(k)\n            except KeyError:\n                raise err\n\n    def getMrNodeType(self):\n        # In compatibility mode, we may or may not have access to the nodeDesc and its information\n        # about the node type.\n        if self.nodeDesc is None:\n            return MrNodeType.NONE\n        return self.nodeDesc.getMrNodeType()\n\n    def getName(self):\n        return self._name\n\n    def getDefaultLabel(self):\n        return self.nameToLabel(self._name)\n\n    def getLabel(self) -> str:\n        \"\"\"\n        Returns:\n            The user-provided label if it exists, the high-level label of this node otherwise\n        \"\"\"\n        if self.hasInternalAttribute(\"label\"):\n            label = self.internalAttribute(\"label\").value.strip()\n            if label:\n                return label\n        return self.getDefaultLabel()\n\n    def getNodeLogLevel(self) -> str:\n        \"\"\"\n        Returns:\n            The user-provided log level used for logging on process launched by this node\n        \"\"\"\n        if self.hasInternalAttribute(\"nodeDefaultLogLevel\"):\n            return self.internalAttribute(\"nodeDefaultLogLevel\").value.strip()\n        return \"info\"\n\n    def getColor(self) -> str:\n        \"\"\"\n        Returns:\n            The node's color: the user-provided custom color if set, otherwise the descriptor's\n            default color (nodeDesc.color), or empty string if neither is defined.\n        \"\"\"\n        if self.hasInternalAttribute(\"color\"):\n            return self.internalAttribute(\"color\").value.strip()\n        return \"\"\n\n    def getInvalidationMessage(self) -> str:\n        \"\"\"\n        Returns:\n            The invalidation message on the node if it exists, empty string otherwise\n        \"\"\"\n        if self.hasInternalAttribute(\"invalidation\"):\n            return self.internalAttribute(\"invalidation\").value\n        return \"\"\n\n    def getComment(self) -> str:\n        \"\"\"\n        Returns:\n            The comments on the node if they exist, empty string otherwise\n        \"\"\"\n        if self.hasInternalAttribute(\"comment\"):\n            return self.internalAttribute(\"comment\").value\n        return \"\"\n\n    def getFontSize(self) -> int:\n        \"\"\"\n        Returns:\n            The font size from the node if it exists, 0 otherwise.\n        \"\"\"\n        if self.hasInternalAttribute(\"fontSize\"):\n            return self.internalAttribute(\"fontSize\").value\n        return 0\n\n    def getFontColor(self) -> str:\n        \"\"\"\n        Returns:\n            The color of the font from the node if it exists, empty string otherwise.\n        \"\"\"\n        if self.hasInternalAttribute(\"fontColor\"):\n            return self.internalAttribute(\"fontColor\").value.strip()\n        return \"\"\n\n    def getNodeWidth(self) -> int:\n        \"\"\"\n        Returns:\n            The width of the node if it has a user-set width, 0 otherwise.\n        \"\"\"\n        if self.hasInternalAttribute(\"nodeWidth\"):\n            return self.internalAttribute(\"nodeWidth\").value\n        return 0\n\n    def getNodeHeight(self) -> int:\n        \"\"\"\n        Returns:\n            The height of the node if it has a user-set height, 0 otherwise.\n        \"\"\"\n        if self.hasInternalAttribute(\"nodeHeight\"):\n            return self.internalAttribute(\"nodeHeight\").value\n        return 0\n\n\n    @Slot(str, result=str)\n    def nameToLabel(self, name):\n        \"\"\"\n        Returns:\n            str: the high-level label from the technical node name\n        \"\"\"\n        t, idx = name.rsplit(\"_\", 1) if \"_\" in name else (name, \"1\")\n        return f\"{t}{idx if int(idx) > 1 else ''}\"\n\n    def getDocumentation(self):\n        if not self.nodeDesc:\n            return \"\"\n        if self.nodeDesc.documentation:\n            return self.nodeDesc.documentation\n        else:\n            return self.nodeDesc.__doc__\n\n    def getNodeInfo(self):\n        if not self.nodeDesc:\n            return []\n        info = OrderedDict([\n            (\"module\", self.nodeDesc.__module__),\n            (\"modulePath\", self.nodeDesc.plugin.path),\n        ])\n        # > Info from the plugin module\n        plugin_module = sys.modules.get(self.nodeDesc.__module__)\n        if getattr(plugin_module, \"__author__\", None):\n            info[\"author\"] = plugin_module.__author__\n        if getattr(plugin_module, \"__license__\", None):\n            info[\"license\"] = plugin_module.__license__\n        if getattr(plugin_module, \"__version__\", None):\n            info[\"version\"] = plugin_module.__version__\n        # > Overrides at the node-level\n        if getattr(self.nodeDesc, \"author\", None):\n            info[\"author\"] = self.nodeDesc.author\n        if getattr(self.nodeDesc, \"version\", None):\n            info[\"version\"] = self.nodeDesc.version\n        # > Additional node information stored in a __nodeInfo__ parameter\n        additionalNodeInfo = getattr(self.nodeDesc, \"__nodeInfo__\", None)\n        if additionalNodeInfo:\n            for key, value in additionalNodeInfo:\n                info[key] = value\n        return [{\"key\": k, \"value\": v} for k, v in info.items()]\n\n    @Slot(str, result=Attribute)\n    def attribute(self, name):\n        att = None\n        # Complex name indicating group or list attribute\n        if '[' in name or '.' in name:\n            p = self.attributeRE.findall(name)\n\n            for n, idx in p:\n                # first step: get root attribute\n                if att is None:\n                    att = self._attributes.get(n)\n                else:\n                    # get child Attribute in Group\n                    assert isinstance(att, GroupAttribute)\n                    att = att.value.get(n)\n                if idx != '':\n                    # get child Attribute in List\n                    assert isinstance(att, ListAttribute)\n                    att = att.value.at(int(idx))\n        else:\n            att = self._attributes.getr(name)\n        return att\n\n    @Slot(str, result=Attribute)\n    def internalAttribute(self, name):\n        # No group or list attributes for internal attributes\n        # The internal attribute itself can be returned directly\n        return self._internalAttributes.get(name)\n\n    def setInternalAttributeValues(self, values):\n        # initialize internal attribute values\n        for k, v in values.items():\n            attr = self.internalAttribute(k)\n            attr.value = v\n\n    def getAttributes(self):\n        return self._attributes\n\n    def getInternalAttributes(self):\n        return self._internalAttributes\n\n    @Slot(str, result=bool)\n    def hasAttribute(self, name):\n        # Complex name indicating group or list attribute: parse it and get the\n        # first output element to check for the attribute's existence\n        if \"[\" in name or \".\" in name:\n            p = self.attributeRE.findall(name)\n            return p[0][0] in self._attributes.keys() or p[0][1] in self._attributes.keys()\n        return name in self._attributes.keys()\n\n    @Slot(str, result=bool)\n    def hasInternalAttribute(self, name):\n        return name in self._internalAttributes.keys()\n\n    def _applyExpr(self):\n        for attr in self._attributes:\n            attr._applyExpr()\n\n    @property\n    def nodeType(self):\n        return self._nodeType\n\n    @property\n    def position(self):\n        \"\"\" Get node position. \"\"\"\n        return self._position\n\n    @position.setter\n    def position(self, value):\n        \"\"\" Set node position.\n\n        Args:\n            value (Position): target position\n        \"\"\"\n        if self._position == value:\n            return\n        self._position = value\n        self.positionChanged.emit()\n\n    @property\n    def alive(self):\n        return self._alive\n\n    @alive.setter\n    def alive(self, value):\n        if self._alive == value:\n            return\n        self._alive = value\n        self.aliveChanged.emit()\n\n    @property\n    def depth(self):\n        return self.graph.getDepth(self)\n\n    @property\n    def minDepth(self):\n        return self.graph.getDepth(self, minimal=True)\n\n    @property\n    def valuesFile(self):\n        return os.path.join(self.internalFolder, 'values')\n\n    def getInputNodes(self, recursive, dependenciesOnly):\n        return self.graph.getInputNodes(self, recursive=recursive,\n                                        dependenciesOnly=dependenciesOnly)\n\n    def getOutputNodes(self, recursive, dependenciesOnly):\n        return self.graph.getOutputNodes(self, recursive=recursive,\n                                         dependenciesOnly=dependenciesOnly)\n\n    def toDict(self):\n        pass\n\n    def _computeUid(self):\n        \"\"\" Compute node UID by combining associated attributes' UIDs. \"\"\"\n        # If there is no invalidating attribute, then the computation of the UID should not\n        # go through as it will only include the node type\n        if not self.invalidatingAttributes:\n            return\n\n        # UID is computed by hashing the sorted list of tuple (name, value) of all attributes\n        # impacting this UID\n        uidAttributes = []\n        for attr in self.invalidatingAttributes:\n            if not attr.enabled:\n                continue  # Disabled params do not contribute to the uid\n            dynamicOutputAttr = attr.isLink and attr.inputRootLink.desc.isDynamicValue\n            # For dynamic output attributes, the UID does not depend on the attribute value.\n            # In particular, when loading a project file, the UIDs are updated first,\n            # and the node status and the dynamic output values are not yet loaded,\n            # so we should not read the attribute value.\n            if not dynamicOutputAttr and not attr.keyable and attr.value == attr.desc.uidIgnoreValue:\n                continue  # For non-dynamic attributes, check if the value should be ignored\n            uidAttributes.append((attr.name, attr.uid()))\n        uidAttributes.sort()\n\n        # Adding the node type prevents ending up with two identical UIDs for different node types\n        # that have the exact same list of attributes\n        uidAttributes.append(self.nodeType)\n        self._uid = hashValue(uidAttributes)\n\n    def _computeInternalFolder(self, cacheDir):\n        self._internalFolder = self._internalFolderExp.format(\n            cache=cacheDir or self.graph.cacheDir,\n            nodeType=self.nodeType,\n            uid=self._uid)\n\n    def _buildExpVars(self):\n        \"\"\"\n        Generate command variables using input attributes and resolved output attributes\n        names and values.\n        \"\"\"\n        def _buildAttributeExpVars(expVars, name, attr):\n            if attr.enabled:\n                # xxValue is exposed without quotes to allow to compose expressions\n                expVars[name + \"Value\"] = attr.getValueStr(withQuotes=False)\n\n                if isinstance(attr, GroupAttribute):\n                    assert isinstance(attr.value, DictModel)\n                    # If the GroupAttribute is not set in a single command line argument,\n                    # the sub-attributes may need to be exposed individually\n                    for v in attr._value:\n                        _buildAttributeExpVars(expVars, v.name, v)\n\n        self._expVars = {\n            \"uid\": self._uid,\n            \"nodeCacheFolder\": self._internalFolder,\n            \"node\": self,\n        }\n\n        # Evaluate input params\n        for name, attr in self._attributes.objects.items():\n            if attr.isOutput:\n                continue  # skip outputs\n            _buildAttributeExpVars(self._expVars, name, attr)\n\n        # For updating output attributes invalidation values\n        expVarsNoCache = self._expVars.copy()\n        expVarsNoCache[\"cache\"] = \"\"\n\n        # Use \"self._internalFolder\" instead of \"self.internalFolder\" because we do not want it to\n        # be resolved with the {cache} information (\"self.internalFolder\" resolves\n        # \"self._internalFolder\")\n        expVarsNoCache[\"nodeCacheFolder\"] = self._internalFolderExp.format(**expVarsNoCache, **self._staticExpVars)\n\n        # Evaluate output params\n        for name, attr in self._attributes.objects.items():\n            if attr.isInput:\n                continue  # skip inputs\n\n            # Apply expressions for File attributes\n            if attr.desc.isExpression:\n                defaultValue = \"\"\n                # Do not evaluate expression for disabled attributes\n                # (the expression may refer to other attributes that are not defined)\n                if attr.enabled:\n                    try:\n                        defaultValue = attr.getDefaultValue()\n                    except AttributeError:\n                        # If we load an old scene, the lambda associated to the 'value' could try to\n                        # access other params that could not exist yet\n                        logging.warning(f'Invalid lambda evaluation for \"{self.name}.{attr.name}\"')\n                    if defaultValue is not None:\n                        try:\n                            attr.value = defaultValue.format(**self._expVars)\n                            attr._invalidationValue = defaultValue.format(**expVarsNoCache)\n                        except KeyError as err:\n                            logging.warning(f'Invalid expression with missing key on \"{self.name}.{attr.name}\" with '\n                                            f'value \"{defaultValue}\".\\nError: {str(err)}')\n                        except ValueError as err:\n                            logging.warning(f'Invalid expression value on \"{self.name}.{attr.name}\" with value '\n                                            f'\"{defaultValue}\".\\nError: {str(err)}')\n\n            # xxValue is exposed without quotes to allow to compose expressions\n            self._expVars[name + 'Value'] = attr.getValueStr(withQuotes=False)\n\n\n    def createCmdLineVars(self):\n        \"\"\"\n        Generate command variables using input attributes and resolved output attributes\n        names and values.\n        \"\"\"\n        def _buildAttributeCmdLineVars(cmdLineVars, name, attr):\n            if attr.enabled:\n                group = attr.desc.commandLineGroup(attr.node) \\\n                        if callable(attr.desc.commandLineGroup) else attr.desc.commandLineGroup\n                if group:\n                    # If there is a valid command line \"group\"\n                    v = attr.getValueStr(withQuotes=True)\n\n                    # List elements may give a fully empty string and will not be sent to the command line.\n                    # String attributes will return only quotes if it is empty and thus will be send to the command line.\n                    # But a List of string containing 1 element,\n                    # and this element is an empty string will also return quotes and will be sent to the command line.\n                    if v:\n                        cmdLineVars[group] = cmdLineVars.get(group, \"\") + f\" --{name} {v}\"\n                elif isinstance(attr, GroupAttribute):\n                    assert isinstance(attr.value, DictModel)\n                    # If the GroupAttribute is not set in a single command line argument,\n                    # the sub-attributes may need to be exposed individually\n                    for v in attr._value:\n                        _buildAttributeCmdLineVars(cmdLineVars, v.name, v)\n\n        cmdLineVars = {}\n\n        # Evaluate input params\n        for name, attr in self._attributes.objects.items():\n            if attr.isOutput:\n                continue  # skip outputs\n            _buildAttributeCmdLineVars(cmdLineVars, name, attr)\n\n        # Evaluate output params\n        for name, attr in self._attributes.objects.items():\n            if attr.isInput:\n                continue  # skip inputs\n            if not attr.desc.commandLineGroup:\n                continue  # skip attributes without group\n\n            v = attr.getValueStr(withQuotes=True)\n\n            if not v:\n                continue  # skip empty strings\n\n            cmdLineVars[attr.desc.commandLineGroup] = \\\n                cmdLineVars.get(attr.desc.commandLineGroup, '') + f' --{name} {v}'\n\n        return cmdLineVars\n\n    @property\n    def isParallelized(self):\n        return bool(self.nodeDesc.parallelization) if meshroom.useMultiChunks else False\n\n    @property\n    def cpu(self):\n        \"\"\" Return the resolved CPU level for this node, by evaluating the descriptor's `cpu`\n        attribute with this node instance if it is callable. \"\"\"\n        if self.nodeDesc is None:\n            return None\n        return self.nodeDesc.resolvedCpu(self)\n\n    @property\n    def gpu(self):\n        \"\"\" Return the resolved GPU level for this node, by evaluating the descriptor's `gpu`\n        attribute with this node instance if it is callable. \"\"\"\n        if self.nodeDesc is None:\n            return None\n        return self.nodeDesc.resolvedGpu(self)\n\n    @property\n    def ram(self):\n        \"\"\" Return the resolved RAM level for this node, by evaluating the descriptor's `ram`\n        attribute with this node instance if it is callable. \"\"\"\n        if self.nodeDesc is None:\n            return None\n        return self.nodeDesc.resolvedRam(self)\n\n    def hasStatus(self, status: Status):\n        if not self._chunks or not self._chunksCreated:\n            if self.isInputNode:\n                return status == Status.INPUT\n            return status == Status.NONE\n        for chunk in self._chunks:\n            if chunk.status.status != status:\n                return False\n        return True\n\n    def _isComputed(self):\n        if not self.isComputableType:\n            return True\n        return self.hasStatus(Status.SUCCESS)\n\n    def _isComputableType(self):\n        \"\"\" Return True if this node type is computable, False otherwise.\n        A computable node type can be in a context that does not allow computation.\n        \"\"\"\n        # Ambiguous case for NONE, which could be used for compatibility nodes if we do not have\n        # any information about the node descriptor.\n        return self.getMrNodeType() != MrNodeType.INPUT and self.getMrNodeType() != MrNodeType.BACKDROP\n\n    def clearData(self):\n        \"\"\" Delete this Node internal folder.\n        Status will be reset to Status.NONE\n        \"\"\"\n        # Clear cache\n        self._nodeStatus.reset()\n        # Reset chunks\n        self._resetChunks()\n        if self.internalFolder and os.path.exists(self.internalFolder):\n            try:\n                shutil.rmtree(self.internalFolder)\n            except Exception as exc:\n                # We could get some \"Device or resource busy\" on .nfs file while removing the folder\n                # on Linux network.\n                # On Windows, some output files may be open for visualization and the removal will\n                # fail.\n                # In both cases, we can ignore it.\n                logging.warning(f\"Failed to remove internal folder: '{self.internalFolder}'. Error: {exc}.\")\n            self.updateStatusFromCache()\n\n    @Slot(result=str)\n    def getStartDateTime(self):\n        \"\"\" Return the date (str) of the first running chunk \"\"\"\n        dateTime = [chunk._status.startDateTime for chunk in self._chunks if chunk._status.status\n                    not in (Status.NONE, Status.SUBMITTED) and chunk._status.startDateTime != \"\"]\n        return min(dateTime) if len(dateTime) != 0 else \"\"\n\n    def isAlreadySubmitted(self):\n        if self._chunksCreated:\n            return any(c.isAlreadySubmitted() for c in self._chunks)\n        else:\n            return self._nodeStatus.status in (Status.SUBMITTED, Status.RUNNING)\n\n    def isAlreadySubmittedOrFinished(self):\n        if self._chunksCreated:\n            return all(c.isAlreadySubmittedOrFinished() for c in self._chunks)\n        else:\n            return self._nodeStatus.status in (Status.SUBMITTED, Status.RUNNING, Status.SUCCESS)\n\n    @Slot(result=bool)\n    def isSubmittedOrRunning(self):\n        \"\"\"\n        Return True if all chunks are at least submitted and there is one running chunk,\n        False otherwise.\n        \"\"\"\n        if not self._chunksCreated:\n            return False\n        if not self.isAlreadySubmittedOrFinished():\n            return False\n        for chunk in self._chunks:\n            if chunk.isRunning():\n                return True\n        return False\n\n    @Slot(result=bool)\n    def isRunning(self):\n        \"\"\" Return True if at least one chunk of this Node is running, False otherwise. \"\"\"\n        return any(chunk.isRunning() for chunk in self._chunks)\n\n    @Slot(result=bool)\n    def isFinishedOrRunning(self):\n        \"\"\"\n        Return True if all chunks of this Node is either finished or running, False\n        otherwise.\n        \"\"\"\n        if not self._chunks:\n            return False\n        return all(chunk.isFinishedOrRunning() for chunk in self._chunks)\n\n    @Slot(result=bool)\n    def isPartiallyFinished(self):\n        \"\"\" Return True is at least one chunk of this Node is finished, False otherwise. \"\"\"\n        return any(chunk.isFinished() for chunk in self._chunks)\n\n    def isExtern(self):\n        \"\"\"\n        Return True if at least one chunk of this Node has an external execution mode,\n        False otherwise.\n\n        It is not enough to check whether the first chunk's execution mode is external,\n        because computations may have been started locally, interrupted, and restarted externally.\n        In that case, if the first chunk has completed locally before the computations were\n        interrupted, its execution mode will always be local, even if computations resume\n        externally.\n        \"\"\"\n        if not self._chunksCreated:\n            if self._nodeStatus.execMode == ExecMode.EXTERN:\n                return True\n            elif self._nodeStatus.execMode == ExecMode.LOCAL and self._nodeStatus.status in (Status.SUBMITTED, Status.RUNNING):\n                return meshroom.core.sessionUid != self._nodeStatus.submitterSessionUid\n            return False\n        return any(chunk.isExtern() for chunk in self._chunks)\n\n    @Slot()\n    def clearSubmittedChunks(self):\n        \"\"\"\n        Reset all submitted chunks to Status.NONE. This method should be used to clear\n        inconsistent status if a computation failed without informing the graph.\n\n        Warnings:\n            This must be used with caution. This could lead to inconsistent node status\n            if the graph is still being computed.\n        \"\"\"\n        if self._chunksCreated:\n            for chunk in self._chunks:\n                if chunk.isAlreadySubmitted():\n                    chunk.upgradeStatusTo(Status.NONE, ExecMode.NONE)\n        else:\n            if self.isAlreadySubmitted():\n                self.upgradeStatusTo(Status.NONE, ExecMode.NONE)\n        self.globalStatusChanged.emit()\n\n    def clearLocallySubmittedChunks(self):\n        \"\"\" Reset all locally submitted chunks to Status.NONE. \"\"\"\n        if self._chunksCreated:\n            for chunk in self._chunks:\n                if chunk.isAlreadySubmitted() and not chunk.isExtern():\n                    chunk.upgradeStatusTo(Status.NONE, ExecMode.NONE)\n        else:\n            if self.isAlreadySubmitted() and not self.isExtern():\n                self.upgradeStatusTo(Status.NONE, ExecMode.NONE)\n        self.globalStatusChanged.emit()\n\n    def upgradeStatusTo(self, newStatus, execMode=None):\n        \"\"\" Upgrade node to the given status and save it on disk. \"\"\"\n        if self._chunksCreated:\n            for chunk in self._chunks:\n                chunk.upgradeStatusTo(newStatus)\n        else:\n            if execMode is not None:\n                self._nodeStatus.execMode = execMode\n            self._nodeStatus.status = newStatus\n            self.upgradeStatusFile()\n            chunkPlaceholder = NodeChunk(self, desc.computation.Range())\n            chunkPlaceholder._status.execMode = self._nodeStatus.execMode\n            chunkPlaceholder._status.status = self._nodeStatus.status\n            self.chunkPlaceholder.setObjectList([chunkPlaceholder])\n            self.chunksChanged.emit()\n        self.globalStatusChanged.emit()\n\n    def updateStatisticsFromCache(self):\n        for chunk in self._chunks:\n            chunk.updateStatisticsFromCache()\n\n    def _resetChunks(self):\n        pass\n\n    def createChunksFromCache(self):\n        pass\n\n    def _createChunks(self):\n        pass\n\n    def evaluateSize(self):\n        \"\"\"\n        Evaluate the node size by delegating to the descriptor's resolvedSize classmethod.\n        \"\"\"\n        return self.nodeDesc.resolvedSize(self)\n\n    def _updateNodeSize(self):\n        self.setSize(self.evaluateSize())\n\n    def _getAttributeChangedCallback(self, attr: Attribute) -> Optional[Callable]:\n        \"\"\" Get the node descriptor-defined value changed callback associated to `attr` if any. \"\"\"\n\n        # Callbacks cannot be defined on nested attributes.\n        if attr.root is not None:\n            return None\n\n        attrCapitalizedName = attr.name[:1].upper() + attr.name[1:]\n        callbackName = f\"on{attrCapitalizedName}Changed\"\n\n        callback = getattr(self.nodeDesc, callbackName, None)\n        return callback if callback and callable(callback) else None\n\n    def _onAttributeChanged(self, attr: Attribute):\n        \"\"\"\n        When an attribute value has changed, a specific function can be defined in the descriptor\n        and be called.\n\n        Args:\n            attr: The Attribute that has changed.\n        \"\"\"\n\n        if self.isCompatibilityNode:\n            # Compatibility nodes are not meant to be updated.\n            return\n\n        if attr.isOutput and not self.isInputNode:\n            # Ignore changes on output attributes for non-input nodes\n            # as they are updated during the node's computation.\n            # And we do not want notifications during the graph processing.\n            return\n\n        if not attr.keyable and attr.value is None:\n            # Discard dynamic values depending on the graph processing.\n            return\n\n        if self.graph and self.graph.isLoading:\n            # Do not trigger attribute callbacks during the graph loading.\n            return\n\n        callback = self._getAttributeChangedCallback(attr)\n\n        if callback:\n            callback(self)\n\n        if self.graph:\n            # If we are in a graph, propagate the notification to the connected output attributes\n            for edge in self.graph.outEdges(attr):\n                edge.dst.valueChanged.emit()\n\n    def onAttributeClicked(self, attr):\n        \"\"\"\n        When an attribute is clicked, a specific function can be defined in the descriptor\n        and be called.\n\n        Args:\n            attr (Attribute): attribute that has been clicked\n        \"\"\"\n        paramName = attr.name[:1].upper() + attr.name[1:]\n        methodName = f'on{paramName}Clicked'\n        if hasattr(self.nodeDesc, methodName):\n            m = getattr(self.nodeDesc, methodName)\n            if callable(m):\n                m(self)\n\n    def updateInternals(self, cacheDir=None):\n        \"\"\" Update Node's internal parameters and output attributes.\n\n        This method is called when:\n         - an input parameter is modified\n         - the graph main cache directory is changed\n\n        Args:\n            cacheDir (str): (optional) override graph's cache directory with custom path\n        \"\"\"\n        if self.nodeDesc:\n            self.nodeDesc.update(self)\n\n        for attr in self._attributes:\n            attr.updateInternals()\n\n        # Reset chunks splitting\n        self._resetChunks()\n\n        # Retrieve current internal folder (if possible)\n        try:\n            folder = self.internalFolder\n        except KeyError:\n            folder = ''\n\n        # Update command variables / output attributes\n        self._computeUid()\n        self._computeInternalFolder(cacheDir)\n        self._buildExpVars()\n        if self.nodeDesc:\n            self.nodeDesc.postUpdate(self)\n        # Notify internal folder change if needed\n        if self._internalFolder != folder:\n            self.internalFolderChanged.emit()\n\n    def updateInternalAttributes(self):\n        self.internalAttributesChanged.emit()\n\n    @property\n    def internalFolder(self):\n        return self._internalFolder\n\n    @property\n    def sourceCodeFolder(self):\n        return self._sourceCodeFolder\n\n    @property\n    def nodeStatusFile(self):\n        return os.path.join(self.graph.cacheDir, self.internalFolder, \"nodeStatus\")\n\n    def shouldMonitorChanges(self):\n        \"\"\" Check whether we should monitor changes in minimal mode.\n        Only chunks that are run externally or local_isolated should be monitored,\n        when run locally, status changes are already notified.\n        Chunks with an ERROR status may be re-submitted externally and should thus still be monitored\n        \"\"\"\n        if self._chunksCreated:\n            # Only monitor when chunks are not created (in this case monitor chunk status files instead)\n            return False\n        return (self.isExtern() and self._nodeStatus.status in (Status.SUBMITTED, Status.RUNNING, Status.ERROR)) or \\\n               (self.getMrNodeType() == MrNodeType.NODE and self._nodeStatus.status in (Status.SUBMITTED, Status.RUNNING))\n\n    def updateNodeStatusFromCache(self):\n        \"\"\"\n        Update node status based on status file content/existence.\n        # TODO : integrate nodeStatusFileLastModTime ?\n        Returns True if a change on the chunk setup has been detected\n        \"\"\"\n        chunksRangeHasChanged = False\n        if os.path.exists(self.nodeStatusFile):\n            oldChunkSetup = self._nodeStatus.chunks\n            self._nodeStatus.loadFromCache(self.nodeStatusFile)\n            if self._nodeStatus.chunks != oldChunkSetup:\n                chunksRangeHasChanged = True\n            self.nodeStatusFileLastModTime = os.path.getmtime(self.nodeStatusFile)\n        else:\n            # No status file => reset status to Status.None\n            self.nodeStatusFileLastModTime = -1\n            self._nodeStatus.reset()\n        self._nodeStatus.setNodeType(self)\n        return chunksRangeHasChanged\n\n    def updateStatusFromCache(self):\n        \"\"\" Update node status based on status file content/existence. \"\"\"\n        # Update nodeStatus from cache\n        chunkChanged = self.updateNodeStatusFromCache()\n        # Create chunks if we found info on them on the node cache\n        if chunkChanged and self._nodeStatus.nbChunks > 0:\n            # Update number of chunks\n            try:\n                self.createChunksFromCache()\n            except Exception as e:\n                logging.warning(f\"Could not create chunks from cache: {e}\")\n                return\n        s = self.globalStatus\n        if self._chunksCreated:\n            for chunk in self._chunks:\n                chunk.updateStatusFromCache()\n        else:\n            # Restore placeholder chunk if needed\n            chunkPlaceholder = NodeChunk(self, desc.computation.Range())\n            chunkPlaceholder._status.execMode = self._nodeStatus.execMode\n            chunkPlaceholder._status.status = self._nodeStatus.status\n            self._chunkPlaceholder.setObjectList([chunkPlaceholder])\n        # logging.debug(f\"updateStatusFromCache: {self.name}, status: {s} => {self.globalStatus}\")\n        self.updateOutputAttr()\n\n    def upgradeStatusFile(self):\n        \"\"\" Write node status on disk. \"\"\"\n        # Make sure the node has the globalStatus before saving it\n        self._nodeStatus.status = self.getGlobalStatus()\n        data = self._nodeStatus.toDict()\n        statusFilepath = self.nodeStatusFile\n        folder = os.path.dirname(statusFilepath)\n        os.makedirs(folder, exist_ok=True)\n        statusFilepathWriting = getWritingFilepath(statusFilepath)\n        with open(statusFilepathWriting, 'w') as jsonFile:\n            json.dump(data, jsonFile, indent=4)\n        renameWritingToFinalPath(statusFilepathWriting, statusFilepath)\n\n    def setJobId(self, jid, submitterName):\n        self._nodeStatus.setJob(jid, submitterName)\n        self.upgradeStatusFile()\n\n    def initStatusOnSubmit(self, forceCompute=False):\n        \"\"\" Prepare chunks status when the node is in a graph that was submitted \"\"\"\n        hasChunkToLaunch = False\n        if not self._chunksCreated:\n            hasChunkToLaunch = True\n        for chunk in self._chunks:\n            if forceCompute or chunk._status.status != Status.SUCCESS:\n                hasChunkToLaunch = True\n                chunk._status.setNode(self)\n                chunk._status.initExternSubmit()\n                chunk.upgradeStatusFile()\n        if hasChunkToLaunch:\n            self._nodeStatus.setNode(self)\n            self._nodeStatus.initExternSubmit()\n            self.upgradeStatusFile()\n            self.globalStatusChanged.emit()\n            if self._nodeStatus.execMode == ExecMode.EXTERN and self._nodeStatus.status in (Status.RUNNING, Status.SUBMITTED):\n                chunkPlaceholder = NodeChunk(self, desc.computation.Range())\n                chunkPlaceholder._status.execMode = self._nodeStatus.execMode\n                chunkPlaceholder._status.status = self._nodeStatus.status\n                self._chunkPlaceholder.setObjectList([chunkPlaceholder])\n                self.chunksChanged.emit()\n\n    def initStatusOnCompute(self, forceCompute=False):\n        hasChunkToLaunch = False\n        if not self._chunksCreated:\n            hasChunkToLaunch = True\n        for chunk in self._chunks:\n            if forceCompute or (chunk._status.status not in (Status.RUNNING, Status.SUCCESS)):\n                hasChunkToLaunch = True\n                chunk._status.setNode(self)\n                chunk._status.initLocalSubmit()\n                chunk.upgradeStatusFile()\n        if hasChunkToLaunch:\n            self._nodeStatus.setNode(self)\n            self._nodeStatus.initLocalSubmit()\n            self.upgradeStatusFile()\n            self.globalStatusChanged.emit()\n            if self._nodeStatus.execMode == ExecMode.LOCAL and self._nodeStatus.status in (Status.RUNNING, Status.SUBMITTED):\n                chunkPlaceholder = NodeChunk(self, desc.computation.Range())\n                chunkPlaceholder._status.execMode = self._nodeStatus.execMode\n                chunkPlaceholder._status.status = self._nodeStatus.status\n                self._chunkPlaceholder.setObjectList([chunkPlaceholder])\n                self.chunksChanged.emit()\n\n    def processIteration(self, iteration):\n        self._chunks[iteration].process()\n\n    def preprocess(self):\n        # Invoke the Node Description's pre-process for the Client Node to prepare its processing\n        self.nodeDesc.preprocess(self)\n\n    def process(self, forceCompute=False, inCurrentEnv=False):\n        for chunk in self._chunks:\n            chunk.process(forceCompute, inCurrentEnv)\n\n    def postprocess(self):\n        # Invoke the post process on Client Node to execute after the processing on the\n        # node is completed\n        self.nodeDesc.postprocess(self)\n\n    def getLogHandlers(self):\n        return self._handlers\n\n    def prepareLogger(self, iteration=-1):\n        # Get file handler path\n        chunkIndex = self.chunks[iteration].index if iteration != -1 else 0\n        logFileName = f\"{chunkIndex}.log\"\n        logFile = os.path.join(self.internalFolder, logFileName)\n        # Setup logger\n        rootLogger = logging.getLogger()\n        self._logManager = LogManager(rootLogger, logFile)\n        self._logManager.clearLogFile()\n        self._logManager.start(self.getNodeLogLevel())\n\n    def restoreLogger(self):\n        self._logManager.restorePreviousLogger()\n\n    def updateOutputAttr(self):\n        if not self.nodeDesc:\n            return\n        if not self.nodeDesc.hasDynamicOutputAttribute:\n            return\n        # logging.warning(f\"updateOutputAttr: {self.name}, status: {self.globalStatus}\")\n        if Status.SUCCESS in [c._status.status for c in self.getChunks()]:\n            self.loadOutputAttr()\n        else:\n            self.resetOutputAttr()\n\n    def resetOutputAttr(self):\n        if not self.nodeDesc.hasDynamicOutputAttribute:\n            return\n        # logging.warning(\"resetOutputAttr: {}\".format(self.name))\n        for output in self.nodeDesc.outputs:\n            if output.isDynamicValue:\n                if self.hasAttribute(output.name):\n                    self.attribute(output.name).value = None\n                else:\n                    logging.warning(f\"resetOutputAttr: Missing dynamic output attribute: {self.name}.{output.name}\")\n\n    def loadOutputAttr(self):\n        \"\"\" Load output attributes with dynamic values from a values.json file.\n        \"\"\"\n        if not self.nodeDesc.hasDynamicOutputAttribute:\n            return\n        valuesFile = self.valuesFile\n        if not os.path.exists(valuesFile):\n            logging.warning(f\"No output attr file: {valuesFile}\")\n            return\n\n        # logging.warning(\"load output attr: {}, value: {}\".format(self.name, valuesFile))\n        with open(valuesFile) as jsonFile:\n            data = json.load(jsonFile)\n\n        # logging.warning(data)\n        for output in self.nodeDesc.outputs:\n            if output.isDynamicValue:\n                if self.hasAttribute(output.name) and output.name in data:\n                    self.attribute(output.name).value = data[output.name]\n                else:\n                    if not self.hasAttribute(output.name):\n                        logging.warning(f\"loadOutputAttr: Missing dynamic output attribute. Node={self.name}, \"\n                                        f\"Attribute={output.name}\")\n                    if output.name not in data:\n                        logging.warning(f\"loadOutputAttr: Missing dynamic output value in file. Node={self.name}, \"\n                                        f\"Attribute={output.name}, File={valuesFile}, Data keys={data.keys()}\")\n\n    def saveOutputAttr(self):\n        \"\"\" Save output attributes with dynamic values into a values.json file.\n        \"\"\"\n        if not self.nodeDesc.hasDynamicOutputAttribute:\n            return\n        data = {}\n        for output in self.nodeDesc.outputs:\n            if output.isDynamicValue:\n                if self.hasAttribute(output.name):\n                    data[output.name] = self.attribute(output.name).value\n                else:\n                    logging.warning(f\"saveOutputAttr: Missing dynamic output attribute: {self.name}.{output.name}\")\n\n        valuesFile = self.valuesFile\n        # logging.warning(\"save output attr: {}, value: {}\".format(self.name, valuesFile))\n        valuesFilepathWriting = getWritingFilepath(valuesFile)\n        with open(valuesFilepathWriting, 'w') as jsonFile:\n            json.dump(data, jsonFile, indent=4)\n        renameWritingToFinalPath(valuesFilepathWriting, valuesFile)\n\n    def endSequence(self):\n        pass\n\n    def stopComputation(self):\n        \"\"\" Stop the computation of this node. \"\"\"\n        if self._chunks:\n            for chunk in self._chunks.values():\n                chunk.stopProcess()\n        else:\n            # Ensure that we are up-to-date\n            self.updateNodeStatusFromCache()\n            # The only status possible here is submitted\n            if self._nodeStatus.status is Status.SUBMITTED:\n                self.upgradeStatusTo(Status.NONE)\n\n    def getGlobalStatus(self):\n        \"\"\"\n        Get node global status based on the status of its chunks.\n\n        Returns:\n            Status: the node global status\n        \"\"\"\n        if self.isInputNode:\n            return Status.INPUT\n        if not self._chunksCreated:\n            # Get status from nodeStatus\n            return self._nodeStatus.status\n        if not self._chunks:\n            return Status.NONE\n        if len(self._chunks) == 1:\n            return self._chunks[0]._status.status\n\n        chunksStatus = [chunk._status.status for chunk in self._chunks]\n\n        anyOf = (Status.ERROR, Status.STOPPED, Status.KILLED,\n                 Status.RUNNING, Status.SUBMITTED)\n        allOf = (Status.SUCCESS,)\n\n        for status in anyOf:\n            if any(s == status for s in chunksStatus):\n                return status\n        for status in allOf:\n            if all(s == status for s in chunksStatus):\n                return status\n\n        return Status.NONE\n\n    @Slot(result=ChunkStatusData)\n    def getFusedStatus(self):\n        if not self._chunks:\n            return ChunkStatusData()\n        fusedStatus = ChunkStatusData()\n        fusedStatus.fromDict(self._chunks[0]._status.toDict())\n        for chunk in self._chunks[1:]:\n            fusedStatus.merge(chunk._status)\n        fusedStatus.status = self.getGlobalStatus()\n        return fusedStatus\n\n    @Slot(result=ChunkStatusData)\n    def getRecursiveFusedStatus(self):\n        fusedStatus = self.getFusedStatus()\n        nodes = self.getInputNodes(recursive=True, dependenciesOnly=True)\n        for node in nodes:\n            fusedStatus.merge(node.fusedStatus)\n        return fusedStatus\n\n    def _isCompatibilityNode(self):\n        return False\n\n    def _isInputNode(self):\n        return isinstance(self.nodeDesc, desc.InputNode)\n\n    def _isBackdropNode(self) -> bool:\n        return False\n\n    @property\n    def globalExecMode(self):\n        if not self._chunksCreated:\n            return self._nodeStatus.execMode.name\n        if len(self._chunks):\n            return self._chunks.at(0).getExecModeName()\n        else:\n            return ExecMode.NONE\n\n    def _getJobName(self):\n        execMode = self._nodeStatus.execMode\n        if execMode == ExecMode.LOCAL:\n            return \"LOCAL\"\n        elif execMode == ExecMode.EXTERN:\n            return self._nodeStatus.jobName\n        else:\n            return \"NONE\"\n\n    def getChunks(self) -> list[NodeChunk]:\n        return self._chunks\n\n    def getSize(self):\n        return self._size\n\n    def setSize(self, value):\n        if self._size == value:\n            return\n        self._size = value\n        self.sizeChanged.emit()\n\n    def __repr__(self):\n        return self.name\n\n    def getLocked(self):\n        return self._locked\n\n    def setLocked(self, lock):\n        if self._locked == lock:\n            return\n        self._locked = lock\n        self.lockedChanged.emit()\n\n    @Slot()\n    def updateDuplicatesStatusAndLocked(self):\n        \"\"\" Update status of duplicate nodes without any latency and update locked. \"\"\"\n        if self.isMainNode():\n            for node in self._duplicates:\n                node.updateStatusFromCache()\n\n            self.updateLocked()\n\n    def updateLocked(self):\n        currentStatus = self.getGlobalStatus()\n\n        lockedStatus = (Status.RUNNING, Status.SUBMITTED)\n\n        # Unlock required nodes if the current node changes to Error, Stopped or None\n        # Warning: we must handle some specific cases for global start/stop\n        if self._locked and currentStatus in (Status.ERROR, Status.STOPPED, Status.NONE):\n            self.setLocked(False)\n            inputNodes = self.getInputNodes(recursive=True, dependenciesOnly=True)\n\n            for node in inputNodes:\n                if node.getGlobalStatus() == Status.RUNNING:\n                    # Return without unlocking if at least one input node is running\n                    # Example: using Cancel Computation on a submitted node\n                    return\n            for node in inputNodes:\n                node.setLocked(False)\n            return\n\n        # Avoid useless travel through nodes\n        # For instance: when loading a scene with successful nodes\n        if not self._locked and currentStatus == Status.SUCCESS:\n            return\n\n        if currentStatus == Status.SUCCESS:\n            # At this moment, the node is necessarily locked because of previous if statement\n            inputNodes = self.getInputNodes(recursive=True, dependenciesOnly=True)\n            outputNodes = self.getOutputNodes(recursive=True, dependenciesOnly=True)\n            stayLocked = None\n\n            # Check if at least one dependentNode is submitted or currently running\n            for node in outputNodes:\n                if node.getGlobalStatus() in lockedStatus and node.isMainNode():\n                    stayLocked = True\n                    break\n            if not stayLocked:\n                self.setLocked(False)\n                # Unlock every input node\n                for node in inputNodes:\n                    node.setLocked(False)\n            return\n        elif currentStatus in lockedStatus and self.isMainNode():\n            self.setLocked(True)\n            inputNodes = self.getInputNodes(recursive=True, dependenciesOnly=True)\n            for node in inputNodes:\n                node.setLocked(True)\n            return\n\n        self.setLocked(False)\n\n    def updateDuplicates(self, nodesPerUid):\n        \"\"\" Update the list of duplicate nodes (sharing the same UID). \"\"\"\n        if not nodesPerUid or not self._uid:\n            if len(self._duplicates) > 0:\n                self._duplicates.clear()\n                self._hasDuplicates = False\n                self.hasDuplicatesChanged.emit()\n            return\n\n        newList = [node for node in nodesPerUid.get(self._uid) if node != self]\n\n        # If number of elements in both lists are identical,\n        # we must check if their content is the same\n        if len(newList) == len(self._duplicates):\n            newListName = {node.name for node in newList}\n            oldListName = {node.name for node in self._duplicates.values()}\n\n            # If strict equality between both sets,\n            # there is no need to set the new list\n            if newListName == oldListName:\n                return\n\n        # Set the newList\n        self._duplicates.setObjectList(newList)\n        # Emit a specific signal 'hasDuplicates' to avoid extra binding\n        # re-evaluation when the number of duplicates has changed\n        if bool(len(newList)) != self._hasDuplicates:\n            self._hasDuplicates = bool(len(newList))\n            self.hasDuplicatesChanged.emit()\n\n    def initFromThisSession(self) -> bool:\n        \"\"\" Check if the node was submitted from the current session \"\"\"\n        if not self._chunksCreated or not self._chunks:\n            return meshroom.core.sessionUid == self._nodeStatus.submitterSessionUid\n        for chunk in self._chunks:\n            # Technically the check on chunk._status.computeSessionUid is useless\n            if meshroom.core.sessionUid not in (chunk._status.computeSessionUid, self._nodeStatus.submitterSessionUid):\n                return False\n        return True\n\n    def isMainNode(self) -> bool:\n        \"\"\" In case of a node with duplicates, we check that the node is the one driving the computation. \"\"\"\n        if len(self._chunks) == 0:\n            return True\n        firstChunk = self._chunks.at(0)\n        if not firstChunk.statusNodeName:\n            # If nothing is declared, anyone could become the main (if there are duplicates).\n            return True\n        return firstChunk.statusNodeName == self.name\n\n    @Slot(result=bool)\n    def canBeStopped(self) -> bool:\n        \"\"\"\n        Return True if this node can be stopped, False otherwise. A node can be stopped if:\n        - it has the \"RUNNING\" status (it is currently being computed)\n        - it is executed locally and started from this Meshroom session OR it is executed externally on a render farm\n          (and is thus associated to a job name). A node that is executed externally but without an associated job is\n          likely a node that was started from another Meshroom instance, and thus cannot be stopped from this one.\n        \"\"\"\n        if not self.isComputableType:\n            return False\n        if self.isCompatibilityNode:\n            return False\n        # Only locked nodes running in local with the same\n        # computeSessionUid as the Meshroom instance can be stopped\n        return (self.getGlobalStatus() == Status.RUNNING and self.isMainNode() and\n                (\n                    (self.globalExecMode == ExecMode.LOCAL.name and self.initFromThisSession())\n                    or\n                    (self.globalExecMode == ExecMode.EXTERN.name and self._nodeStatus.jobName != \"UNKNOWN\")\n                )\n        )\n\n    @Slot(result=bool)\n    def canBeCanceled(self) -> bool:\n        \"\"\"\n        Return True if this node can be canceled, False otherwise. A node can be canceled if:\n        - it has the \"SUBMITTED\" status (it is not running yet, but is expected to be in the near future)\n        - it is executed locally and started from this Meshroom session OR it is executed externally on a render farm\n          (and is thus associated to a job name). A node that is executed externally but without an associated job is\n          likely a node that was started from another Meshroom instance, and thus cannot be canceled from this one.\n        \"\"\"\n        if not self.isComputableType:\n            return False\n        if self.isCompatibilityNode:\n            return False\n        # Only locked nodes submitted in local with the same\n        # computeSessionUid as the Meshroom instance can be canceled\n        return (self.getGlobalStatus() == Status.SUBMITTED and self.isMainNode() and\n                (\n                    (self.globalExecMode == ExecMode.LOCAL.name and self.initFromThisSession())\n                    or\n                    (self.globalExecMode == ExecMode.EXTERN.name and self._nodeStatus.jobName != \"UNKNOWN\")\n                )\n        )\n\n    def hasImageOutputAttribute(self) -> bool:\n        \"\"\"\n        Return True if at least one attribute has the 'image' semantic (and can thus be loaded in\n        the 2D Viewer), False otherwise.\n        \"\"\"\n        for attr in self._attributes:\n            if not attr.enabled or not attr.isOutput:\n                continue\n            if attr.desc.semantic == \"image\":\n                return True\n        return False\n\n    def hasSequenceOutputAttribute(self) -> bool:\n        \"\"\"\n        Return True if at least one attribute has the 'sequence' semantic (and can thus be loaded in\n        the 2D Viewer), False otherwise.\n        \"\"\"\n        for attr in self._attributes:\n            if not attr.enabled or not attr.isOutput:\n                continue\n            if attr.desc.semantic in (\"sequence\", \"imageList\"):\n                return True\n        return False\n\n    def has3DOutputAttribute(self):\n        \"\"\"\n        Return True if at least one attribute is a File that can be loaded in the 3D Viewer,\n        False otherwise.\n        \"\"\"\n        return next((attr for attr in self._attributes if attr.enabled and attr.isOutput and attr.is3dDisplayable), None) is not None\n\n    def hasTextOutputAttribute(self) -> bool:\n        \"\"\"\n        Return True if at least one attribute is a text file that can be loaded in the Text Viewer,\n        False otherwise.\n        \"\"\"\n        return next((attr for attr in self._attributes if attr.enabled and attr.isOutput and attr.isTextDisplayable), None) is not None\n\n    def _hasDisplayableShape(self):\n        \"\"\"\n        Return True if at least one attribute is a ShapeAttribute, a ShapeListAttribute or a shape File.\n        Note: These attributes can be loaded in the ShapeViewer / ShapeEditor.\n        False otherwise.\n        \"\"\"\n        return next((attr for attr in self._attributes if attr.hasDisplayableShape or\n                     attr.desc.semantic == \"shapeFile\"), None) is not None\n\n\n    nodeNameChanged = Signal()\n    name = Property(str, getName, notify=nodeNameChanged)\n    defaultLabel = Property(str, getDefaultLabel, constant=True)\n    nodeType = Property(str, nodeType.fget, constant=True)\n    documentation = Property(str, getDocumentation, constant=True)\n    nodeInfo = Property(Variant, getNodeInfo, constant=True)\n    nodeStatusChanged = Signal()\n    nodeStatus = Property(Variant, lambda self: self._nodeStatus, notify=nodeStatusChanged)\n    nodeStatusNodeName = Property(str, lambda self: self._nodeStatus.nodeName, notify=nodeStatusChanged)\n    positionChanged = Signal()\n    position = Property(Variant, position.fget, position.fset, notify=positionChanged)\n    x = Property(float, lambda self: self._position.x, notify=positionChanged)\n    y = Property(float, lambda self: self._position.y, notify=positionChanged)\n    attributes = Property(BaseObject, getAttributes, constant=True)\n    internalAttributes = Property(BaseObject, getInternalAttributes, constant=True)\n    internalAttributesChanged = Signal()\n    label = Property(str, getLabel, notify=internalAttributesChanged)\n    color = Property(str, getColor, notify=internalAttributesChanged)\n    invalidation = Property(str, getInvalidationMessage, notify=internalAttributesChanged)\n    comment = Property(str, getComment, notify=internalAttributesChanged)\n    fontSize = Property(int, getFontSize, notify=internalAttributesChanged)\n    fontColor = Property(str, getFontColor, notify=internalAttributesChanged)\n    nodeWidth = Property(int, getNodeWidth, notify=internalAttributesChanged)\n    nodeHeight = Property(int, getNodeHeight, notify=internalAttributesChanged)\n    internalFolderChanged = Signal()\n    internalFolder = Property(str, internalFolder.fget, notify=internalFolderChanged)\n    valuesFile = Property(str, valuesFile.fget, notify=internalFolderChanged)\n    depthChanged = Signal()\n    depth = Property(int, depth.fget, notify=depthChanged)\n    minDepth = Property(int, minDepth.fget, notify=depthChanged)\n    chunksCreatedChanged = Signal()\n    chunksCreated = Property(bool, lambda self: self._chunksCreated, notify=chunksCreatedChanged)\n    chunksChanged = Signal()\n    chunks = Property(Variant, getChunks, notify=chunksChanged)\n    chunkPlaceholder = Property(Variant, lambda self: self._chunkPlaceholder, notify=chunksChanged)\n    nbParallelizationBlocks = Property(int, lambda self: len(self._chunks) if self._chunksCreated else 0, notify=chunksChanged)\n    sizeChanged = Signal()\n    size = Property(int, getSize, notify=sizeChanged)\n    globalStatusChanged = Signal()\n    globalStatus = Property(str, lambda self: self.getGlobalStatus().name, notify=globalStatusChanged)\n    fusedStatus = Property(ChunkStatusData, getFusedStatus, notify=globalStatusChanged)\n    elapsedTime = Property(float, lambda self: self.getFusedStatus().elapsedTime, notify=globalStatusChanged)\n    recursiveElapsedTime = Property(float, lambda self: self.getRecursiveFusedStatus().elapsedTime,\n                                    notify=globalStatusChanged)\n    isCompatibilityNode = Property(bool, lambda self: self._isCompatibilityNode(), constant=True)\n    isInputNode = Property(bool, lambda self: self._isInputNode(), constant=True)\n    isBackdropNode = Property(bool, lambda self: self._isBackdropNode(), constant=True)\n\n    globalExecMode = Property(str, globalExecMode.fget, notify=globalStatusChanged)\n    jobName = Property(str, lambda self: self._getJobName(), notify=globalStatusChanged)\n    isExternal = Property(bool, isExtern, notify=globalStatusChanged)\n    isComputed = Property(bool, _isComputed, notify=globalStatusChanged)\n    isComputableType = Property(bool, _isComputableType, notify=globalStatusChanged)\n    aliveChanged = Signal()\n    alive = Property(bool, alive.fget, alive.fset, notify=aliveChanged)\n    lockedChanged = Signal()\n    locked = Property(bool, getLocked, setLocked, notify=lockedChanged)\n    duplicates = Property(Variant, lambda self: self._duplicates, constant=True)\n    hasDuplicatesChanged = Signal()\n    hasDuplicates = Property(bool, lambda self: self._hasDuplicates, notify=hasDuplicatesChanged)\n\n    outputAttrChanged = Signal()\n    hasImageOutput = Property(bool, hasImageOutputAttribute, notify=outputAttrChanged)\n    hasSequenceOutput = Property(bool, hasSequenceOutputAttribute, notify=outputAttrChanged)\n    has3DOutput = Property(bool, has3DOutputAttribute, notify=outputAttrChanged)\n    hasTextOutput = Property(bool, hasTextOutputAttribute, notify=outputAttrChanged)\n    # Whether the node contains a ShapeAttribute, a ShapeListAttribute or a shape File.\n    hasDisplayableShape = Property(bool, _hasDisplayableShape, constant=True)\n\n\nclass Node(BaseNode):\n    \"\"\"\n    A standard Graph node based on a node type.\n    \"\"\"\n    def __init__(self, nodeType, position=None, parent=None, uid=None, **kwargs):\n        super().__init__(nodeType, position, parent=parent, uid=uid, **kwargs)\n\n        if not self.nodeDesc:\n            raise UnknownNodeTypeError(nodeType)\n\n        self.packageName = self.nodeDesc.packageName\n\n        for attrDesc in self.nodeDesc.inputs:\n            self._attributes.add(attributeFactory(attrDesc, kwargs.get(attrDesc.name, None),\n                                                  isOutput=False, node=self))\n\n        for attrDesc in self.nodeDesc.outputs:\n            self._attributes.add(attributeFactory(attrDesc, kwargs.get(attrDesc.name, None),\n                                                  isOutput=True, node=self))\n\n        for attrDesc in self.nodeDesc.internalInputs:\n            self._internalAttributes.add(attributeFactory(attrDesc, kwargs.get(attrDesc.name, None),\n                                                          isOutput=False, node=self))\n\n        # Declare events for specific output attributes\n        for attr in self._attributes:\n            if attr.isOutput and attr.desc.semantic == \"image\":\n                attr.enabledChanged.connect(self.outputAttrChanged)\n            if attr.isOutput:\n                attr.expressionApplied.connect(self.outputAttrChanged)\n\n        # List attributes per UID\n        for attr in self._attributes:\n            if attr.isInput and attr.invalidate:\n                self.invalidatingAttributes.add(attr)\n\n        # Add internal attributes with a UID to the list\n        for attr in self._internalAttributes:\n            if attr.invalidate:\n                self.invalidatingAttributes.add(attr)\n\n    def setAttributeValues(self, values):\n        # initialize attribute values\n        for k, v in values.items():\n            if not self.hasAttribute(k):\n                # skip missing attributes\n                continue\n            attr = self.attribute(k)\n            attr.value = v\n\n    def upgradeAttributeValues(self, values):\n        # initialize attribute values\n        for k, v in values.items():\n            if not self.hasAttribute(k):\n                # skip missing attributes\n                continue\n            attr = self.attribute(k)\n            try:\n                attr.upgradeValue(v)\n            except ValueError:\n                pass\n\n    def setInternalAttributeValues(self, values):\n        # initialize internal attribute values\n        for k, v in values.items():\n            if not self.hasInternalAttribute(k):\n                # skip missing attributes\n                continue\n            attr = self.internalAttribute(k)\n            attr.value = v\n\n    def upgradeInternalAttributeValues(self, values):\n        # initialize internal attibute values\n        for k, v in values.items():\n            if not self.hasInternalAttribute(k):\n                # skip missing atributes\n                continue\n            attr = self.internalAttribute(k)\n            try:\n                attr.upgradeValue(v)\n            except ValueError:\n                pass\n\n    def toDict(self):\n        inputs = {k: v.getSerializedValue() for k, v in self._attributes.objects.items() if v.isInput}\n        internalInputs = {k: v.getSerializedValue() for k, v in self._internalAttributes.objects.items()}\n        outputs = ({k: v.getSerializedValue() for k, v in self._attributes.objects.items()\n                    if v.isOutput and not v.desc.isDynamicValue})\n\n        return {\n            'nodeType': self.nodeType,\n            'position': self._position,\n            'parallelization': {\n                'blockSize': self.nodeDesc.parallelization.blockSize if self.isParallelized else 0,\n                'size': self.size,\n                'split': self.nbParallelizationBlocks\n            },\n            'uid': self._uid,\n            'inputs': {k: v for k, v in inputs.items() if v is not None},  # filter empty values\n            'internalInputs': {k: v for k, v in internalInputs.items() if v is not None},\n            'outputs': outputs,\n        }\n\n    def _resetChunks(self):\n        \"\"\" Set chunks on the node.\n        # TODO : Maybe do not delete chunks if we will recreate them as before ?\n        \"\"\"\n        if self.isInputNode:\n            self._chunksCreated = True\n            return\n        # Disconnect signals\n        for chunk in self._chunks:\n            chunk.statusChanged.disconnect(self.globalStatusChanged)\n        # Empty list\n        self._chunks.setObjectList([])\n        self._chunkPlaceholder.setObjectList([])\n        # Reset node status to ensure getGlobalStatus() returns NONE during the reset.\n        # This prevents updateLocked() from using a stale status (e.g. SUCCESS or SUBMITTED)\n        # which could cause the node to be incorrectly locked.\n        self._nodeStatus.status = Status.NONE\n        # Recreate list with reset values (1 chunk or the static size)\n        if not self.isParallelized:\n            self._chunks.setObjectList([NodeChunk(self, desc.Range())])\n            self._chunks[0].statusChanged.connect(self.globalStatusChanged)\n            self._chunksCreated = True\n        elif isinstance(self.nodeDesc.size, desc.computation.StaticNodeSize):\n            self._updateNodeSize()\n            self._chunks.setObjectList([NodeChunk(self, desc.Range())])\n            self._chunks[0].statusChanged.connect(self.globalStatusChanged)\n            self._chunksCreated = True\n            try:\n                ranges = self.nodeDesc.parallelization.getRanges(self)\n                self._chunks.setObjectList([NodeChunk(self, range) for range in ranges])\n                for c in self._chunks:\n                    c.statusChanged.connect(self.globalStatusChanged)\n                logging.debug(f\"Created {len(self._chunks)} chunks for node: {self.name}\")\n            except RuntimeError:\n                # TODO: set node internal status to error\n                logging.warning(f\"Invalid Parallelization on node {self._name}\")\n                self._chunks.clear()\n                self._chunksCreated = False\n        else:\n            self._chunksCreated = False\n            self.setSize(0)\n            self._chunkPlaceholder.setObjectList([NodeChunk(self, desc.computation.Range())])\n\n        # Create chunks when possible\n        self.chunksCreatedChanged.emit()\n        self.chunksChanged.emit()\n        self.globalStatusChanged.emit()\n\n    def __createChunks(self, ranges):\n        if self.isParallelized:\n            try:\n                if len(ranges) != len(self._chunks):\n                    self._chunks.setObjectList([NodeChunk(self, range) for range in ranges])\n                    for c in self._chunks:\n                        c.statusChanged.connect(self.globalStatusChanged)\n                    logging.debug(f\"Created {len(self._chunks)} chunks for node: {self.name}\")\n                else:\n                    for chunk, range in zip(self._chunks, ranges):\n                        chunk.range = range\n            except RuntimeError:\n                # TODO: set node internal status to error\n                logging.warning(f\"Invalid Parallelization on node {self._name}\")\n                self._chunks.clear()\n        else:\n            if len(self._chunks) != 1:\n                self._chunks.setObjectList([NodeChunk(self, desc.Range())])\n                self._chunks[0].statusChanged.connect(self.globalStatusChanged)\n            else:\n                self._chunks[0].range = desc.Range()\n        self._chunksCreated = True\n        # Update node status\n        # TODO: update all chunks status?\n        # TODO: update node status?\n        # Emit signals for UI updates\n        self.chunksChanged.emit()\n        self.chunksCreatedChanged.emit()\n\n    def createChunksFromCache(self):\n        \"\"\" Create chunks when a node cache exists. \"\"\"\n        try:\n            # Get size from cache\n            size = self._nodeStatus.fullSize\n            self.setSize(size)\n            ranges = self._nodeStatus.getChunkRanges()\n            self.__createChunks(ranges)\n        except Exception as e:\n            logging.error(f\"Failed to create chunks for {self.name}\")\n            self._chunks.clear()\n            self._chunksCreated = False\n            raise e\n\n    def createChunks(self):\n        \"\"\" Create chunks when computation is about to start. \"\"\"\n        if self._chunksCreated:\n            return\n        if self.isInputNode:\n            self._chunksCreated = True\n            self.chunksChanged.emit()\n            return\n        # Grab current chunk information\n        logging.debug(f\"Creating chunks for node: {self.name}\")\n        try:\n            size = self.evaluateSize()\n            self.setSize(size)\n            ranges = self.nodeDesc.parallelization.getRanges(self)\n            self.__createChunks(ranges)\n        except Exception as e:\n            logging.error(f\"Failed to create chunks for {self.name}: {e}\")\n            self._chunks.clear()\n            self._chunksCreated = False\n            raise e\n        # Update status\n        self._nodeStatus.setChunks(self._chunks)\n        self.upgradeStatusFile()\n\n\nclass BackdropNode(BaseNode):\n    def __init__(self, nodeType: str, position=None, parent=None, uid=None, **kwargs):\n        super().__init__(nodeType, position, parent=parent, uid=uid, **kwargs)\n\n        self._chunksCreated = True\n\n        if not self.nodeDesc:\n            raise UnknownNodeTypeError(nodeType)\n\n        self.packageName = self.nodeDesc.packageName\n\n        for attrDesc in self.nodeDesc.internalInputs:\n            self._internalAttributes.add(attributeFactory(attrDesc, kwargs.get(attrDesc.name, None),\n                                                          isOutput=False, node=self))\n\n    def _isBackdropNode(self) -> bool:\n        return True\n\n    def toDict(self):\n        internalInputs = {k: v.getSerializedValue() for k, v in self._internalAttributes.objects.items()}\n\n        return {\n            'nodeType': self.nodeType,\n            'position': self._position,\n            'parallelization': {\n                'blockSize': 0,\n                'size': 0,\n                'split': 0\n            },\n            'uid': self._uid,\n            'internalInputs': {k: v for k, v in internalInputs.items() if v is not None},\n        }\n\n\nclass CompatibilityIssue(Enum):\n    \"\"\"\n    Enum describing compatibility issues when deserializing a Node.\n    \"\"\"\n    UnknownIssue = 0  # unknown issue fallback\n    UnknownNodeType = 1  # the node type has no corresponding description class\n    VersionConflict = 2  # mismatch between node's description version and serialized node data\n    DescriptionConflict = 3  # mismatch between node's description attributes and serialized node data\n    UidConflict = 4  # mismatch between computed UIDs and UIDs stored in serialized node data\n    PluginIssue = 5  # issue when loading the associated plugin\n\n\nclass CompatibilityNode(BaseNode):\n    \"\"\"\n    Fallback BaseNode subclass to instantiate Nodes having compatibility issues with current type description.\n    CompatibilityNode creates an 'empty-shell' exposing the deserialized node as-is,\n    with all its inputs and precomputed outputs.\n    \"\"\"\n    def __init__(self, nodeType, nodeDict, position=None, issue=CompatibilityIssue.UnknownIssue, parent=None):\n        super().__init__(nodeType, position, parent)\n\n        self.issue = issue\n        # Make a deepcopy of nodeDict to handle CompatibilityNode duplication\n        # and be able to change modified inputs (see CompatibilityNode.toDict)\n        self.nodeDict = copy.deepcopy(nodeDict)\n        version = self.nodeDict.get(\"version\")\n        self.version = Version(version) if version else None\n\n        self._inputs = self.nodeDict.get(\"inputs\", {})\n        self._internalInputs = self.nodeDict.get(\"internalInputs\", {})\n        self.outputs = self.nodeDict.get(\"outputs\", {})\n        self._uid = self.nodeDict.get(\"uid\", None)\n\n        # Restore parallelization settings\n        self.parallelization = self.nodeDict.get(\"parallelization\", {})\n        self.splitCount = self.parallelization.get(\"split\", 1)\n        self.setSize(self.parallelization.get(\"size\", 1))\n\n        # Create input attributes\n        for attrName, value in self._inputs.items():\n            self._addAttribute(attrName, value, isOutput=False)\n\n        # Create outputs attributes\n        for attrName, value in self.outputs.items():\n            self._addAttribute(attrName, value, isOutput=True)\n\n        # Create internal attributes\n        for attrName, value in self._internalInputs.items():\n            self._addAttribute(attrName, value, isOutput=False, internalAttr=True)\n\n        # Create NodeChunks matching serialized parallelization settings\n        self._chunks.setObjectList([\n            NodeChunk(self, desc.Range(i, blockSize=self.parallelization.get(\"blockSize\", 0)))\n            for i in range(self.splitCount)\n        ])\n\n    def _isCompatibilityNode(self):\n        return True\n\n    def _updateNodeSize(self):\n        # Block the recompute of the node size for compatibility nodes\n        pass\n\n    @staticmethod\n    def attributeDescFromValue(attrName, value, isOutput):\n        \"\"\"\n        Generate an attribute description (desc.Attribute) that best matches 'value'.\n\n        Args:\n            attrName (str): the name of the attribute\n            value: the value of the attribute\n            isOutput (bool): whether the attribute is an output\n\n        Returns:\n            desc.Attribute: the generated attribute description\n        \"\"\"\n        params = {\n            \"name\": attrName, \"label\": attrName,\n            \"description\": \"Incompatible parameter\",\n            \"value\": value, \"invalidate\": False,\n            \"commandLineGroup\": \"incompatible\"\n        }\n        if isinstance(value, bool):\n            return desc.BoolParam(**params)\n        if isinstance(value, int):\n            return desc.IntParam(range=None, **params)\n        elif isinstance(value, float):\n            return desc.FloatParam(range=None, **params)\n        elif isinstance(value, str):\n            if isOutput or os.path.isabs(value):\n                return desc.File(**params)\n            elif Attribute.isLinkExpression(value):\n                # Do not consider link expression as a valid default desc value.\n                # When the link expression is applied and transformed to an actual link,\n                # the systems resets the value using `Attribute.resetToDefaultValue` to indicate\n                # that this link expression has been handled.\n                # If the link expression is stored as the default value, it will never be cleared,\n                # leading to unexpected behavior where the link expression on a CompatibilityNode\n                # could be evaluated several times and/or incorrectly.\n                params[\"value\"] = \"\"\n                return desc.File(**params)\n            else:\n                return desc.StringParam(**params)\n        # List/GroupAttribute: recursively build descriptions\n        elif isinstance(value, (list, dict)):\n            del params[\"value\"]\n            del params[\"invalidate\"]\n            attrDesc = None\n            if isinstance(value, list):\n                elt = value[0] if value else \"\"  # Fallback: empty string value if list is empty\n                eltDesc = CompatibilityNode.attributeDescFromValue(\"element\", elt, isOutput)\n                attrDesc = desc.ListAttribute(elementDesc=eltDesc, **params)\n            elif isinstance(value, dict):\n                items = []\n                for key, value in value.items():\n                    eltDesc = CompatibilityNode.attributeDescFromValue(key, value, isOutput)\n                    items.append(eltDesc)\n                attrDesc = desc.GroupAttribute(items=items, **params)\n            # Override empty default value with\n            attrDesc._value = value\n            return attrDesc\n        # Handle any other type of parameters as Strings\n        return desc.StringParam(**params)\n\n    @staticmethod\n    def attributeDescFromName(refAttributes, name, value, strict=True):\n        \"\"\"\n        Try to find a matching attribute description in refAttributes for given attribute\n        'name' and 'value'.\n\n        Args:\n            refAttributes ([desc.Attribute]): reference Attributes to look for a description\n            name (str): attribute's name\n            value: attribute's value\n            strict: strict test for the match (for instance, regarding a group with some parameter changes)\n\n        Returns:\n            desc.Attribute: an attribute description from refAttributes if a match is found, None otherwise.\n        \"\"\"\n        # from original node description based on attribute's name\n        attrDesc = next((d for d in refAttributes if d.name == name), None)\n        if attrDesc is None:\n            return None\n        # We have found a description, and we still need to\n        # check if the value matches the attribute description.\n        #\n        # If it is a serialized link expression (no proper value to set/evaluate)\n        if Attribute.isLinkExpression(value):\n            return attrDesc\n\n        # If it is a GroupAttribute, all the attributes within the group should be matched\n        # individually so that links can correctly be evaluated.\n        if isinstance(attrDesc, desc.GroupAttribute):\n            for k, v in value.items():\n                if CompatibilityNode.attributeDescFromName(attrDesc.items,\n                                                           k, v, strict=True) is None:\n                    return None\n            return attrDesc\n\n        # If it passes the 'matchDescription' test\n        if attrDesc.matchDescription(value, strict):\n            return attrDesc\n\n        return None\n\n    def _addAttribute(self, name, val, isOutput, internalAttr=False):\n        \"\"\"\n        Add a new attribute on this node.\n\n        Args:\n            name (str): the name of the attribute\n            val: the attribute's value\n            isOutput: whether the attribute is an output\n            internalAttr: whether the attribute is internal\n\n        Returns:\n            bool: whether the attribute exists in the node description\n        \"\"\"\n        attrDesc = None\n        if self.nodeDesc:\n            if internalAttr:\n                refAttrs = self.nodeDesc.internalInputs\n            else:\n                refAttrs = self.nodeDesc.outputs if isOutput else self.nodeDesc.inputs\n            attrDesc = CompatibilityNode.attributeDescFromName(refAttrs, name, val)\n        matchDesc = attrDesc is not None\n        if attrDesc is None:\n            attrDesc = CompatibilityNode.attributeDescFromValue(name, val, isOutput)\n        attribute = attributeFactory(attrDesc, val, isOutput, self)\n        if internalAttr:\n            self._internalAttributes.add(attribute)\n        else:\n            self._attributes.add(attribute)\n        return matchDesc\n\n    @property\n    def issueDetails(self):\n        if self.issue == CompatibilityIssue.UnknownNodeType:\n            return f\"Unknown node type: '{self.nodeType}'.\"\n        elif self.issue == CompatibilityIssue.VersionConflict:\n            version = self.nodeDict[\"version\"]\n            return f\"Node version '{version}' conflicts with current version '{nodeVersion(self.nodeDesc)}'.\"\n        elif self.issue == CompatibilityIssue.DescriptionConflict:\n            return \"Node attributes do not match node description.\"\n        elif self.issue == CompatibilityIssue.UidConflict:\n            return \"Node UID differs from the expected one.\"\n        else:\n            return \"Unknown error.\"\n\n    @property\n    def inputs(self):\n        \"\"\" Get current node inputs, where links could differ from original serialized node data\n        (i.e after node duplication) \"\"\"\n        # if node has not been added to a graph, return serialized node inputs\n        if not self.graph:\n            return self._inputs\n        return {k: v.getSerializedValue() for k, v in self._attributes.objects.items() if v.isInput}\n\n    @property\n    def internalInputs(self):\n        \"\"\" Get current node's internal attributes \"\"\"\n        if not self.graph:\n            return self._internalInputs\n        return {k: v.getSerializedValue() for k, v in self._internalAttributes.objects.items()}\n\n    def toDict(self):\n        \"\"\"\n        Return the original serialized node that generated a compatibility issue.\n\n        Serialized inputs are updated to handle instances that have been duplicated\n        and might be connected to different nodes.\n        \"\"\"\n        # update inputs to get up-to-date connections\n        self.nodeDict.update({\"inputs\": self.inputs})\n        # update position\n        self.nodeDict.update({\"position\": self.position})\n        return self.nodeDict\n\n    @property\n    def canUpgrade(self):\n        \"\"\" Return whether the node can be upgraded.\n        This is the case when the underlying node type has a corresponding description. \"\"\"\n        return self.nodeDesc is not None\n\n    def upgrade(self):\n        \"\"\"\n        Return a new Node instance based on original node type with common inputs initialized.\n        \"\"\"\n        if not self.canUpgrade:\n            raise NodeUpgradeError(self.name, \"No matching node type\")\n\n        # inputs matching current type description\n        commonInputs = []\n        for attrName, value in self._inputs.items():\n            if self.attributeDescFromName(self.nodeDesc.inputs, attrName, value, strict=False):\n                # store attributes that could be used during node upgrade\n                commonInputs.append(attrName)\n\n        commonInternalAttributes = []\n        for attrName, value in self._internalInputs.items():\n            if self.attributeDescFromName(self.nodeDesc.internalInputs, attrName, value, strict=False):\n                # store internal attributes that could be used during node upgrade\n                commonInternalAttributes.append(attrName)\n\n        node = Node(self.nodeType, position=self.position)\n        # convert attributes from a list of tuples into a dict\n        attrValues = {key: value for (key, value) in self.inputs.items()}\n        intAttrValues = {key: value for (key, value) in self.internalInputs.items()}\n\n        # Use upgrade method of the node description itself if available\n        try:\n            upgradedAttrValues = node.nodeDesc.upgradeAttributeValues(attrValues, self.version)\n        except Exception as exc:\n            logging.error(f\"Error in the upgrade implementation of the node: {self.name}.\\n{repr(exc)}\")\n            upgradedAttrValues = attrValues\n\n        if not isinstance(upgradedAttrValues, dict):\n            logging.error(f\"Error in the upgrade implementation of the node: {self.name}. The return type is incorrect.\")\n            upgradedAttrValues = attrValues\n\n        node.upgradeAttributeValues(upgradedAttrValues)\n\n        node.upgradeInternalAttributeValues(intAttrValues)\n\n        return node\n\n    compatibilityIssue = Property(int, lambda self: self.issue.value, constant=True)\n    canUpgrade = Property(bool, canUpgrade.fget, constant=True)\n    issueDetails = Property(str, issueDetails.fget, constant=True)\n"
  },
  {
    "path": "meshroom/core/nodeFactory.py",
    "content": "import logging\nfrom typing import Any, Optional, Union\nfrom collections.abc import Iterable\n\nimport meshroom.core\nfrom meshroom.core import Version, desc\nfrom meshroom.core.node import BackdropNode, CompatibilityIssue, CompatibilityNode, Node, Position\n\n\ndef nodeFactory(\n    nodeData: dict,\n    name: Optional[str] = None,\n    inTemplate: bool = False,\n    expectedUid: Optional[str] = None,\n) -> Union[Node, BackdropNode, CompatibilityNode]:\n    \"\"\"\n    Create a node instance by deserializing the given node data.\n    If the serialized data matches the corresponding node type description, a Node instance is created.\n    If any compatibility issue occurs, a NodeCompatibility instance is created instead.\n\n    Args:\n        nodeData: The serialized Node data.\n        name: The node's name.\n        inTemplate: True if the node is created as part of a graph template.\n        expectedUid: The expected UID of the node within the context of a Graph.\n\n    Returns:\n        The created Node instance.\n    \"\"\"\n    return _NodeCreator(nodeData, name, inTemplate, expectedUid).create()\n\n\ndef getNodeConstructor(nodeType: str, position: Optional[Position]=None, **kwargs) -> Union[BackdropNode, Node]:\n    constructors = {\n        \"Backdrop\": BackdropNode,\n    }\n    constructor = constructors.get(nodeType, Node)\n    return constructor(nodeType, position=position, **kwargs)\n\n\nclass _NodeCreator:\n\n    def __init__(\n        self,\n        nodeData: dict,\n        name: Optional[str] = None,\n        inTemplate: bool = False,\n        expectedUid: Optional[str] = None,\n    ):\n        self.nodeData = nodeData\n        self.name = name\n        self.inTemplate = inTemplate\n        self.expectedUid = expectedUid\n\n        self._normalizeNodeData()\n\n        self.nodeType = self.nodeData[\"nodeType\"]\n        self.inputs = self.nodeData.get(\"inputs\", {})\n        self.internalInputs = self.nodeData.get(\"internalInputs\", {})\n        self.outputs = self.nodeData.get(\"outputs\", {})\n        self.version = self.nodeData.get(\"version\", None)\n        self.position = Position(*self.nodeData.get(\"position\", []))\n        self.uid = self.nodeData.get(\"uid\", None)\n        self.nodeDesc = None\n        if meshroom.core.pluginManager.isRegistered(self.nodeType):\n            self.nodeDesc = meshroom.core.pluginManager.getRegisteredNodePlugin(self.nodeType).nodeDescriptor\n\n    def create(self) -> Union[Node, BackdropNode, CompatibilityNode]:\n        compatibilityIssue = self._checkCompatibilityIssues()\n        if compatibilityIssue:\n            node = self._createCompatibilityNode(compatibilityIssue)\n            node = self._tryUpgradeCompatibilityNode(node)\n        else:\n            node = self._createNode()\n        return node\n\n    def _normalizeNodeData(self):\n        \"\"\"Consistency fixes for backward compatibility with older serialized data.\"\"\"\n        # Inputs were previously saved as \"attributes\".\n        if \"inputs\" not in self.nodeData and \"attributes\" in self.nodeData:\n            self.nodeData[\"inputs\"] = self.nodeData[\"attributes\"]\n            del self.nodeData[\"attributes\"]\n\n    def _checkCompatibilityIssues(self) -> Optional[CompatibilityIssue]:\n        if self.nodeDesc is None:\n            if meshroom.core.pluginManager.belongsToPlugin(self.nodeType) is not None:\n                return CompatibilityIssue.PluginIssue\n            return CompatibilityIssue.UnknownNodeType\n\n        if not self._checkUidCompatibility():\n            return CompatibilityIssue.UidConflict\n\n        if not self._checkVersionCompatibility():\n            return CompatibilityIssue.VersionConflict\n\n        if not self._checkDescriptionCompatibility():\n            return CompatibilityIssue.DescriptionConflict\n\n        return None\n\n    def _checkUidCompatibility(self) -> bool:\n        return self.expectedUid is None or self.expectedUid == self.uid\n\n    def _checkVersionCompatibility(self) -> bool:\n        # Special case: a node with a version set to None indicates\n        # that it has been created from the current version of the node type.\n        nodeCreatedFromCurrentVersion = self.version is None\n        if nodeCreatedFromCurrentVersion:\n            return True\n        nodeTypeCurrentVersion = meshroom.core.nodeVersion(self.nodeDesc)\n        # If the node type has not current version information, assume compatibility.\n        if nodeTypeCurrentVersion is None:\n            return True\n        return Version(self.version).major == Version(nodeTypeCurrentVersion).major\n\n    def _checkDescriptionCompatibility(self) -> bool:\n        # Only perform strict attribute name matching for non-template graphs,\n        # since only non-default-value input attributes are serialized in templates.\n        if not self.inTemplate:\n            if not self._checkAttributesNamesMatchDescription():\n                return False\n\n        return self._checkAttributesAreCompatibleWithDescription()\n\n    def _checkAttributesNamesMatchDescription(self) -> bool:\n        return (\n            self._checkInputAttributesNames()\n            and self._checkOutputAttributesNames()\n            and self._checkInternalAttributesNames()\n        )\n\n    def _checkAttributesAreCompatibleWithDescription(self) -> bool:\n        return (\n            self._checkAttributesCompatibility(self.nodeDesc.inputs, self.inputs)\n            and self._checkAttributesCompatibility(self.nodeDesc.internalInputs,\n                                                   self.internalInputs)\n            and self._checkAttributesCompatibility(self.nodeDesc.outputs, self.outputs)\n        )\n\n    def _checkInputAttributesNames(self) -> bool:\n        def serializedInput(attr: desc.Attribute) -> bool:\n            \"\"\" Filter that excludes not-serialized desc input attributes. \"\"\"\n            if isinstance(attr, desc.PushButtonParam):\n                # PushButtonParam are not serialized has they do not hold a value.\n                return False\n            return True\n\n        refAttributes = filter(serializedInput, self.nodeDesc.inputs)\n        return self._checkAttributesNamesStrictlyMatch(refAttributes, self.inputs)\n\n    def _checkOutputAttributesNames(self) -> bool:\n        def serializedOutput(attr: desc.Attribute) -> bool:\n            \"\"\" Filter that excludes not-serialized desc output attributes. \"\"\"\n            if attr.isDynamicValue:\n                # Dynamic outputs values are not serialized with the node,\n                # as their value is written in the computed output data.\n                return False\n            return True\n\n        refAttributes = filter(serializedOutput, self.nodeDesc.outputs)\n        return self._checkAttributesNamesStrictlyMatch(refAttributes, self.outputs)\n\n    def _checkInternalAttributesNames(self) -> bool:\n        invalidatingDescAttributes = [attr.name for attr in self.nodeDesc.internalInputs if attr.invalidate]\n        return all(attr in self.internalInputs.keys() for attr in invalidatingDescAttributes)\n\n    def _checkAttributesNamesStrictlyMatch(\n        self, descAttributes: Iterable[desc.Attribute], attributesDict: dict[str, Any]\n    ) -> bool:\n        refNames = sorted([attr.name for attr in descAttributes])\n        attrNames = sorted(attributesDict.keys())\n        return refNames == attrNames\n\n    def _checkAttributesCompatibility(\n        self, descAttributes: list[desc.Attribute], attributesDict: dict[str, Any]\n    ) -> bool:\n        return all(\n            CompatibilityNode.attributeDescFromName(descAttributes, attrName, value) is not None\n            for attrName, value in attributesDict.items()\n        )\n\n    def _createNode(self) -> Union[BackdropNode, Node]:\n        logging.info(f\"Creating node '{self.name}'\")\n        # TODO: user inputs/outputs may conflicts with internal names (like logLevel, position, uid)\n        # The line below can cause UI issues but at least prevent crashes\n        internalInputs = {k: v for k, v in self.internalInputs.items() if k not in self.inputs.keys()}\n        return getNodeConstructor(\n            self.nodeType,\n            position=self.position,\n            uid=self.uid,\n            **self.inputs,\n            **internalInputs,\n            **self.outputs,\n        )\n\n    def _createCompatibilityNode(self, compatibilityIssue) -> CompatibilityNode:\n        logging.warning(f\"Compatibility issue detected for node '{self.name}': {compatibilityIssue.name}\")\n        return CompatibilityNode(\n            self.nodeType, self.nodeData, position=self.position, issue=compatibilityIssue\n        )\n\n    def _tryUpgradeCompatibilityNode(self, node: CompatibilityNode) -> Union[Node, CompatibilityNode]:\n        \"\"\"Handle possible upgrades of CompatibilityNodes, when no computed data is associated to the Node.\"\"\"\n        if node.issue == CompatibilityIssue.UnknownNodeType:\n            return node\n\n        # Nodes in templates are not meant to hold computation data.\n        if self.inTemplate:\n            logging.warning(f\"Compatibility issue in template: performing automatic upgrade on '{self.name}'\")\n            return node.upgrade()\n\n        # Backward compatibility: \"uid\" was not serialized.\n        if not self.uid:\n            logging.warning(f\"No serialized output data: performing automatic upgrade on '{self.name}'\")\n            return node.upgrade()\n\n        return node\n"
  },
  {
    "path": "meshroom/core/plugins.py",
    "content": "from __future__ import annotations\n\nimport glob\nimport importlib\nimport json\nimport logging\nimport os\nimport re\nimport sys\n\nfrom enum import Enum\nfrom inspect import getfile\nfrom pathlib import Path\n\nfrom meshroom.common import BaseObject\nfrom meshroom.core import desc\nfrom meshroom.core.desc.attribute import ValueTypeErrors\nfrom meshroom.core.desc.node import _MESHROOM_ROOT, _MESHROOM_COMPUTE_DEPS\n\n\ndef validateNodeDesc(nodeDesc: desc.BaseNode) -> list[tuple[str, ValueTypeErrors]]:\n    \"\"\"\n    Check that the node has a valid description before being loaded. For the description\n    to be valid, the default value of every parameter needs to correspond to the type\n    of the parameter.\n    An empty returned list means that every parameter is valid, and so is the node's description.\n    If it is not valid, the returned list contains the names of the invalid parameters. In case\n    of nested parameters (parameters in groups or lists, for example), the name of the parameter\n    follows the name of the parent attributes. For example, if the attribute \"x\", contained in group\n    \"group\", is invalid, then it will be added to the list as \"group:x\".\n\n    Args:\n        nodeDesc: Description of the node.\n\n    Returns:\n        errors: The list of invalid parameters if there are any, empty list otherwise.\n    \"\"\"\n    errors = []\n\n    for param in nodeDesc.inputs:\n        errMsg, errType = param.checkValueTypes()\n        if errMsg:\n            errors.append((errMsg, errType))\n\n    for param in nodeDesc.outputs:\n        if param.value is None:\n            if issubclass(nodeDesc, desc.InputNode):\n                errors.append((f\"{param.name}\", ValueTypeErrors.DYNAMIC_OUTPUT))\n            continue\n        errMsg, errType = param.checkValueTypes()\n        if errMsg:\n            errors.append((errMsg, errType))\n\n    return errors\n\ndef formatNodeDescriptionErrorMessage(error: tuple[str, ValueTypeErrors]) -> str:\n    \"\"\"\n    Format a node description error message from a tuple containing the error message (name of the attribute) and type.\n\n    Args:\n        error: Tuple containing the name of the parameter that was rejected, and the type of the error.\n\n    Returns:\n        str: Formatted error message.\n    \"\"\"\n    errMsg, errType = error\n\n    if errType == ValueTypeErrors.TYPE:\n        return f\"'value': Invalid type for parameter '{errMsg}'.\"\n    if errType == ValueTypeErrors.RANGE:\n        return f\"'range': Invalid range value for parameter '{errMsg}'.\"\n    if errType == ValueTypeErrors.DYNAMIC_OUTPUT:\n        return f\"'value': Unsupported dynamic output for parameter '{errMsg}'.\"\n    return f\"Unknown error for parameter '{errMsg}'.\"\n\n\nclass ProcessEnvType(Enum):\n    \"\"\" Supported process environments. \"\"\"\n    DIRTREE = \"dirtree\",\n    REZ = \"rez\"\n\n\nclass ProcessEnv(BaseObject):\n    \"\"\"\n    Describes the environment required by a node's process.\n\n    Args:\n        folder: the source folder for the process.\n        configEnv: the dictionary containing the environment variables defined in a configuration file\n                   for the process to run.\n        envType: (optional) the type of process environment.\n        uri: (optional) the Unique Resource Identifier to activate the environment.\n    \"\"\"\n\n    def __init__(self, folder: str, configEnv: dict[str, str],\n                 envType: ProcessEnvType = ProcessEnvType.DIRTREE, uri: str = \"\"):\n        super().__init__()\n        self._folder: str = folder\n        self._configEnv: dict[str: str] = configEnv\n        self._processEnvType: ProcessEnvType = envType\n        self.uri: str = uri\n        self._env: dict = None\n\n    def getEnvDict(self) -> dict:\n        \"\"\" Return the environment dictionary if it has been modified, None otherwise. \"\"\"\n        return self._env\n\n    def getCommandPrefix(self) -> str:\n        \"\"\" Return the prefix to the command line that will be executed by the process. \"\"\"\n        return \"\"\n\n    def getCommandSuffix(self) -> str:\n        \"\"\" Return the suffix to the command line that will be executed by the process. \"\"\"\n        return \"\"\n\n\nclass DirTreeProcessEnv(ProcessEnv):\n    \"\"\"\n    \"\"\"\n    def __init__(self, folder: str, configEnv: dict[str: str]):\n        super().__init__(folder, configEnv, envType=ProcessEnvType.DIRTREE)\n\n        # If there is a virtual environment, it is expected to be named \"venv\".\n        # Beside the virtual environment, a standard \"bin\"/\"lib\"/\"lib64\" hierarchy at\n        # the top level of the plugin folder is expected.\n        venvFolder = Path(folder, \"venv\")\n\n        # Find all the libs that are not directly at the \"lib*\"-level\n        envLibPaths = glob.glob(f'{folder}/lib*/python[0-9].[0-9]*/site-packages',\n                                recursive=False)\n        venvLibPaths = glob.glob(f'{venvFolder}/lib*/python[0-9].[0-9]*/site-packages',\n                                 recursive=False)\n\n        self.binPaths: list = [str(Path(folder, \"bin\")), str(Path(venvFolder, \"bin\"))]\n        self.libPaths: list = [str(Path(folder, \"lib\")), str(Path(folder, \"lib64\")),\n                               str(Path(venvFolder, \"lib\")), str(Path(venvFolder, \"lib64\"))]\n        self.pythonPaths: list = [str(Path(folder)), str(Path(venvFolder))] + \\\n                                 self.binPaths + envLibPaths + venvLibPaths\n\n        if sys.platform == \"win32\":\n            # For Windows platforms, try and include the content of the virtual env if it exists\n            # The virtual env is expected to be named \"venv\"\n            venvLibPath = Path(venvFolder, \"Lib\", \"site-packages\")\n            if venvLibPath.exists():\n                self.pythonPaths.append(venvLibPath.as_posix())\n        else:\n            # For Linux platforms, lib paths may need to be discovered recursively to be properly\n            # added to LD_LIBRARY_PATH\n            extraLibPaths = []\n            regex = re.compile(r\"^lib(\\d{2})?$\")\n            for envPath in envLibPaths + venvLibPaths:\n                for path, directories, _ in os.walk(envPath):\n                    for directory in directories:\n                        if re.match(regex, directory):\n                            extraLibPaths.append(os.path.join(path, directory))\n            self.libPaths = self.libPaths + extraLibPaths\n\n        # Setup the environment dictionary\n        self._env = os.environ.copy()\n        self._env[\"PYTHONPATH\"] = os.pathsep.join(\n            [f\"{_MESHROOM_ROOT}\"] + self.pythonPaths + [os.getenv('PYTHONPATH', '')])\n        self._env[\"LD_LIBRARY_PATH\"] = f\"{os.pathsep.join(self.libPaths)}{os.pathsep}{os.getenv('LD_LIBRARY_PATH', '')}\"\n        self._env[\"PATH\"] = f\"{os.pathsep.join(self.binPaths)}{os.pathsep}{os.getenv('PATH', '')}\"\n\n        for k, val in self._configEnv.items():\n            # Preserve user-defined environment variables:\n            # manually set environment variable values take precedence over config file defaults.\n            if k in self._env:\n                continue\n\n            self._env[k] = val\n\n\nclass RezProcessEnv(ProcessEnv):\n    \"\"\"\n    \"\"\"\n\n    REZ_DELIMITER_PATTERN = re.compile(r\"-|==|>=|>|<=|<\")\n\n    def __init__(self, folder: str, configEnv: dict[str: str], uri: str = \"\"):\n        if not uri:\n            raise RuntimeError(\"Missing name of the Rez environment needs to be provided.\")\n        super().__init__(folder, configEnv, envType=ProcessEnvType.REZ, uri=uri)\n\n    def resolveRezSubrequires(self) -> list[str]:\n        \"\"\"\n        Return the list of packages defined for the node execution. These execution packages are\n        named subrequires.\n        Note: If a package does not have a version number, the version is aligned with the main\n        Meshroom environment (if this package is defined).\n        \"\"\"\n        subrequires = os.environ.get(f\"{self.uri.upper()}_SUBREQUIRES\", \"\").split(os.pathsep)\n        if not subrequires:\n            return []\n\n        packages = []\n        # Packages that are resolved in the current environment\n        currentEnvPackages = []\n        resolvedVersions = {}\n        if \"REZ_USED_RESOLVE\" in os.environ:\n            resolvedPackages = os.getenv(\"REZ_USED_RESOLVE\", \"\").split()\n            for package in resolvedPackages:\n                if package.startswith(\"~\"):\n                    continue\n                currentEnvPackages.append(package)\n                name, version = self.REZ_DELIMITER_PATTERN.split(package, maxsplit=1)\n                resolvedVersions[name] = version\n        logging.debug(\"Packages in the current environment: \" + \", \".join(currentEnvPackages))\n\n        # Take packages with the set versions for those which have one, and try to take packages\n        # in the current environment (if they are resolved in it)\n        for package in subrequires:\n            packageTuple = self.REZ_DELIMITER_PATTERN.split(package, maxsplit=1)\n            if len(packageTuple) == 1:\n                # Only the package name in the subrequires.\n                # Search for a corresponding version in the parent environment.\n                packageName = packageTuple[0]\n                parentResolvedVersion = resolvedVersions.get(packageName)\n                if parentResolvedVersion:\n                    packages.append(f\"{packageName}=={parentResolvedVersion}\")\n                else:\n                    packages.append(package)\n            elif len(packageTuple) == 2:\n                # The subrequires ask for a specific version\n                packages.append(package)\n\n        def extractPackageName(packageString: str) -> str:\n            return self.REZ_DELIMITER_PATTERN.split(packageString, maxsplit=1)[0]\n        packageNames = [extractPackageName(package) for package in packages]\n\n        for package in _MESHROOM_COMPUTE_DEPS:\n            # For packages that are required by meshroom_compute, do not specify any version\n            # or align it with Meshroom's: the version will be found during the resolution of\n            # the environment based on the other packages.\n            # If any of these packages is already part of the environment a plugin's dependency,\n            # do not add it\n            if package not in packageNames:\n                packages.append(package)\n\n        logging.debug(\"Packages for the execution environment: \" + \", \".join(packages))\n        return packages\n\n    def getCommandPrefix(self):\n        # TODO: make Windows-compatible\n\n        # Use the PYTHONPATH from the subrequires' environment (which will only be resolved once\n        # inside the execution environment) and add MESHROOM_ROOT and the plugin's folder itself\n        # to it\n        pythonPaths = f\"{os.pathsep.join(['$PYTHONPATH', f'{_MESHROOM_ROOT}', f'{self._folder}'])}\"\n\n        return f\"rez env {' '.join(self.resolveRezSubrequires())} -c 'PYTHONPATH={pythonPaths} \"\n\n    def getCommandSuffix(self):\n        return \"'\"\n\n\ndef processEnvFactory(folder: str, configEnv: dict[str: str], envType: str = \"dirtree\", uri: str = \"\") -> ProcessEnv:\n    if envType == \"dirtree\":\n        return DirTreeProcessEnv(folder, configEnv)\n    return RezProcessEnv(folder, configEnv, uri=uri)\n\n\nclass NodePluginStatus(Enum):\n    \"\"\"\n    Loading status for NodePlugin objects.\n    \"\"\"\n    NOT_LOADED = 0  # The node plugin exists but is not loaded and cannot be used (not registered)\n    LOADED = 1  # The node plugin is currently loaded and functional (it has been registered)\n    DESC_ERROR = 2  # The node plugin exists but has an invalid description\n    LOADING_ERROR = 3  # The node plugin exists and is valid but could not be successfully registered\n    ERROR = 4  # Error when importing the node plugin from its module\n\n\nclass Plugin(BaseObject):\n    \"\"\"\n    A collection of node plugins.\n\n    Members:\n        name: the name of the plugin (e.g. name of the Python module containing the node plugins)\n        path: the absolute path of the plugin\n        nodePlugins: dictionary mapping the name of a node plugin contained in the plugin\n                     to its corresponding NodePlugin object\n        templates: dictionary mapping the name of templates (.mg files) associated to the plugin\n                   with their absolute paths\n        configEnv: the environment variables and their values, as described in the plugin's\n                   configuration file\n        configFullEnv: the static merge of os.environ and configEnv, with os.environ taking precedence\n        processEnv: the environment required for the nodes' processes to be correctly executed\n    \"\"\"\n\n    def __init__(self, name: str, path: str):\n        super().__init__()\n\n        self._name: str = name\n        self._path: str = path\n\n        self._nodePlugins: dict[str: NodePlugin] = {}\n        self._templates: dict[str: str] = {}\n        self._configEnv: dict[str: str] = {}\n        self._configFullEnv: dict[str: str] = {}\n        self._processEnv: ProcessEnv = ProcessEnv(path, self._configEnv)\n\n        self.loadTemplates()\n        self.loadConfig()\n\n    @property\n    def name(self):\n        \"\"\" Return the name of the plugin. \"\"\"\n        return self._name\n\n    @property\n    def path(self):\n        \"\"\" Return the absolute path of the plugin. \"\"\"\n        return self._path\n\n    @property\n    def nodes(self):\n        \"\"\"\n        Return the dictionary containing the NodePlugin objects associated to\n        the plugin.\n        \"\"\"\n        return self._nodePlugins\n\n    @property\n    def templates(self):\n        \"\"\" Return the list of templates associated to the plugin. \"\"\"\n        return self._templates\n\n    @property\n    def processEnv(self):\n        \"\"\" Return the environment required to successfully execute processes. \"\"\"\n        return self._processEnv\n\n    @processEnv.setter\n    def processEnv(self, processEnv: ProcessEnv):\n        \"\"\" Set the environment required to successfully execute processes. \"\"\"\n        self._processEnv = processEnv\n\n    @property\n    def configEnv(self):\n        \"\"\"\n        Return the dictionary containing the environment variables and their values\n        provided in the plugin's configuration file.\n        \"\"\"\n        return self._configEnv\n\n    @property\n    def configFullEnv(self):\n        \"\"\" Return the fusion of the os.environ dictionary with the configEnv dictionary. \"\"\"\n        return self._configFullEnv\n\n    def addNodePlugin(self, nodePlugin: NodePlugin):\n        \"\"\"\n        Add a node plugin to the current plugin object and assign it as its containing plugin.\n        The node plugin is added to the dictionary of node plugins with the name of the node\n        descriptor as its key.\n\n        Args:\n            nodePlugin: the NodePlugin object to add to the Plugin.\n        \"\"\"\n        self._nodePlugins[nodePlugin.nodeDescriptor.__name__] = nodePlugin\n        nodePlugin.plugin = self\n\n    def removeNodePlugin(self, name: str):\n        \"\"\"\n        Remove a node plugin from the current plugin object and delete any container relationship.\n\n        Args:\n            name: the name of the NodePlugin to remove.\n        \"\"\"\n        if name in self._nodePlugins:\n            self._nodePlugins[name].plugin = None\n            del self._nodePlugins[name]\n        else:\n            logging.warning(f\"Node plugin {name} is not part of the plugin {self.name}.\")\n\n    def loadTemplates(self):\n        \"\"\"\n        Load all the pipeline templates that are available within the plugin folder.\n        Whenever this method is called, the list of templates for the plugin is cleared,\n        before being filled again.\n        \"\"\"\n        self._templates.clear()\n        for file in os.listdir(self.path):\n            if file.endswith(\".mg\"):\n                self._templates[os.path.splitext(file)[0]] = os.path.join(self.path, file)\n\n    def loadConfig(self):\n        \"\"\"\n        Load the plugin's configuration file if it exists and saves all its environment variables\n        and their values, if they are valid.\n        The configuration file is expected to be named \"config.json\", located at the top-level of\n        the plugin.\n        \"\"\"\n        try:\n            with open(os.path.join(self.path, \"config.json\")) as config:\n                content = json.load(config)\n                for entry in content:\n                    # An entry is expected to be formatted as follows:\n                    # { \"key\": \"key_of_var\", \"type\": \"type_of_value\", \"value\": \"var_value\" }\n                    # If \"type\" is not provided, it is assumed to be \"string\"\n                    k = entry.get(\"key\", None)\n                    t = entry.get(\"type\", None)\n                    val = entry.get(\"value\", None)\n\n                    if not k or not val:\n                        logging.warning(f\"Invalid entry in configuration file for {self.name}: {entry}.\")\n                        continue\n\n                    if t == \"path\":\n                        if os.path.isabs(val):\n                            resolvedPath = Path(val).resolve()\n                        else:\n                            resolvedPath = Path(os.path.join(self.path, val)).resolve()\n\n                        if resolvedPath.exists():\n                            val = resolvedPath.as_posix()\n                        else:\n                            logging.debug(f\"{k}: {resolvedPath.as_posix()} does not exist \"\n                                          f\"(path before resolution: {val}).\")\n\n                    self._configEnv[k] = str(val)\n\n        except FileNotFoundError:\n            logging.debug(f\"No configuration file 'config.json' was found for {self.name}.\")\n        except json.JSONDecodeError as err:\n            logging.error(f\"Malformed JSON in the configuration file for {self.name}: {err}\")\n        except IOError as err:\n            logging.error(f\"Error while accessing the configuration file for {self.name}: {err}\")\n\n        # If both dictionaries have identical keys, os.environ overwrites existing values from _configEnv\n        self._configFullEnv = self._configEnv | os.environ\n\n    def containsNodePlugin(self, name: str) -> bool:\n        \"\"\"\n        Return whether the node plugin \"name\" is part of the plugin, independently from its\n        status.\n\n        Args:\n            name: the name of the node plugin to be checked.\n        \"\"\"\n        return name in self._nodePlugins\n\n\nclass NodePlugin(BaseObject):\n    \"\"\"\n    Based on a node description, a NodePlugin represents a loadable node.\n\n    Members:\n        plugin: the Plugin object that contains this node plugin\n        path: absolute path to the file containing the node's description\n        nodeDescriptor: the description of the node\n        status: the loading status on the node plugin\n        errors: the list of errors (if there are any) when validating the description\n                of the node or attempting to load it\n        processEnv: the environment required for the node plugin's process. It can either\n                    be specific to this node plugin, or be common for all the node plugins within\n                    the plugin\n        timestamp: the timestamp corresponding to the last time the node description's file has been\n                   modified\n    \"\"\"\n\n    def __init__(self, nodeDesc: desc.BaseNode, plugin: Plugin = None):\n        super().__init__()\n        self.plugin: Plugin = plugin\n        self.path: str = Path(getfile(nodeDesc)).resolve().as_posix()\n        self.nodeDescriptor: desc.BaseNode = nodeDesc\n        self.nodeDescriptor.plugin = self\n\n        self.status: NodePluginStatus = NodePluginStatus.NOT_LOADED\n        self.errors: list[tuple[str, ValueTypeErrors]] = validateNodeDesc(nodeDesc)\n\n        if self.errors:\n            self.status = NodePluginStatus.DESC_ERROR\n\n        self._processEnv = None\n        self._timestamp = os.path.getmtime(self.path)\n\n    def reload(self) -> bool:\n        \"\"\"\n        Reload the node plugin and update its status accordingly. If the timestamp of the node plugin's\n        path has not changed since the last time the plugin has been loaded, then nothing will happen.\n\n        Returns:\n            bool: True if the node plugin has successfully been reloaded (i.e. there was no error, and\n                  some changes were made since its last loading), False otherwise.\n        \"\"\"\n        timestamp = 0.0\n        try:\n            timestamp = os.path.getmtime(self.path)\n        except FileNotFoundError:\n            self.status = NodePluginStatus.ERROR\n            logging.error(f\"[Reload] {self.nodeDescriptor.__name__}: The path at {self.path} was not \"\n                          f\"not found.\")\n            return False\n\n        if self._timestamp == timestamp:\n            logging.info(f\"[Reload] {self.nodeDescriptor.__name__}: Not reloading. The node description \"\n                         f\"at {self.path} has not been modified since the last load.\")\n            return False\n\n        try:\n            updated = importlib.reload(sys.modules.get(self.nodeDescriptor.__module__))\n        except Exception as exc:\n            logging.error(f\"[Reload] {self.nodeDescriptor.__name__}: {exc} ({type(exc).__name__})\")\n            self.status = NodePluginStatus.DESC_ERROR\n            return False\n        descriptor = getattr(updated, self.nodeDescriptor.__name__)\n\n        if not descriptor:\n            self.status = NodePluginStatus.ERROR\n            logging.error(f\"[Reload] {self.nodeDescriptor.__name__}: The node description at {self.path} \"\n                          f\"was not found.\")\n            return False\n\n        self.errors = validateNodeDesc(descriptor)\n        if self.errors:\n            self.status = NodePluginStatus.DESC_ERROR\n            logging.error(f\"[Reload] {self.nodeDescriptor.__name__}: The node description at {self.path} \"\n                          f\"has description errors.\")\n            return False\n\n        self.nodeDescriptor = descriptor\n        self.nodeDescriptor.plugin = self\n        self._timestamp = timestamp\n        self.status = NodePluginStatus.NOT_LOADED\n        logging.info(f\"[Reload] {self.nodeDescriptor.__name__}: Successful reloading.\")\n        return True\n\n    @property\n    def plugin(self):\n        \"\"\"\n        Return the Plugin object that contains this node plugin.\n        If the node plugin has not been assigned to a plugin yet, this value will\n        be set to None.\n        \"\"\"\n        return self._plugin\n\n    @plugin.setter\n    def plugin(self, plugin: Plugin):\n        \"\"\" Assign this node plugin to a containing Plugin object. \"\"\"\n        self._plugin = plugin\n\n    @property\n    def processEnv(self):\n        \"\"\"\"\n        Return the process environment that is specific to the node plugin if it has any.\n        Otherwise, the Plugin's is returned.\n        \"\"\"\n        if self._processEnv:\n            return self._processEnv\n        if self.plugin:\n            return self.plugin.processEnv\n        return None\n\n    @property\n    def runtimeEnv(self) -> dict:\n        \"\"\" Return the environment dictionary for the runtime. \"\"\"\n        return self.processEnv.getEnvDict()\n\n    @property\n    def commandPrefix(self) -> str:\n        \"\"\" Return the command prefix for the NodePlugin's execution. \"\"\"\n        if not self.processEnv:\n            return \"\"\n        return self.processEnv.getCommandPrefix()\n\n    @property\n    def commandSuffix(self) -> str:\n        \"\"\" Return the command suffix for the NodePlugin's execution. \"\"\"\n        if not self.processEnv:\n            return \"\"\n        return self.processEnv.getCommandSuffix()\n\n    @property\n    def configFullEnv(self) -> dict[str: str]:\n        \"\"\" Return the plugin's full environment dictionary. \"\"\"\n        if not self.plugin:\n            return {}\n        return self.plugin.configFullEnv\n\nclass NodePluginManager(BaseObject):\n    \"\"\"\n    Manager for all the loaded Plugin objects as well as the registered NodePlugin objects.\n\n    Members:\n        plugins: dictionary containing all the loaded Plugins, with their name as the key\n        nodePlugins: dictionary containing all the NodePlugins that have been registered\n                      (a NodePlugin may exist without having been registered) with their name as\n                      the key\n    \"\"\"\n\n    def __init__(self):\n        super().__init__()\n\n        self._plugins: dict[str: Plugin] = {}  # loaded plugins\n        self._nodePlugins: dict[str: NodePlugin] = {}  # registered node plugins\n\n    def isRegistered(self, name: str) -> bool:\n        \"\"\"\n        Return whether the node plugin has been registered already.\n\n        Args:\n            name: the name of the node plugin whose registration needs to be checked.\n        \"\"\"\n        return name in self._nodePlugins\n\n    def belongsToPlugin(self, name: str) -> Plugin:\n        \"\"\"\n        Check whether the node plugin belongs to a loaded plugin, independently from\n        whether it has been registered or not.\n\n        Args:\n            name: the name of the node plugin that needs to be searched for across plugins.\n\n        Returns:\n            Plugin | None: the Plugin the node belongs to if it exists, None otherwise.\n        \"\"\"\n        for plugin in self._plugins.values():\n            if plugin.containsNodePlugin(name):\n                return plugin\n        return None\n\n    def getPlugins(self) -> dict[str: Plugin]:\n        \"\"\"\n        Return a dictionary containing all the loaded Plugins, with {key, value} =\n        {name, Plugin}.\n        \"\"\"\n        return self._plugins\n\n    def getPlugin(self, name: str) -> Plugin:\n        \"\"\"\n        Return the loaded Plugin object named \"name\".\n\n        Args:\n            name: the name of the Plugin, used upon its loading.\n\n        Returns:\n            Plugin | None: the loaded Plugin object if it exists, None otherwise.\n        \"\"\"\n        if name in self._plugins:\n            return self._plugins[name]\n        return None\n\n    def addPlugin(self, plugin: Plugin, registerNodePlugins: bool = True):\n        \"\"\"\n        Load a Plugin object.\n\n        Args:\n            plugin: the Plugin to load and add to the list of loaded plugins.\n            registerNodePlugins: True if all the NodePlugins from the plugin should be registered\n                                 at the same time the plugin is being loaded. Otherwise, the\n                                 NodePlugins will have to be registered at a later occasion.\n        \"\"\"\n        if not self.getPlugin(plugin.name):\n            self._plugins[plugin.name] = plugin\n            if registerNodePlugins:\n                for node in plugin.nodes:\n                    self.registerNode(plugin.nodes[node])\n\n    def removePlugin(self, plugin: Plugin, unregisterNodePlugins: bool = True):\n        \"\"\"\n        Remove a loaded Plugin object.\n\n        Args:\n            plugin: the Plugin to remove from the list of loaded plugins.\n            unregisterNodePlugins: True if all the nodes from the plugin should be unregistered (if they\n                                   are registered) at the same time as the plugin is unloaded. Otherwise,\n                                   the registered NodePlugins will remain while the Plugin itself will\n                                   be unloaded.\n        \"\"\"\n        if self.getPlugin(plugin.name):\n            if unregisterNodePlugins:\n                for node in plugin.nodes.values():\n                    self.unregisterNode(node)\n            del self._plugins[plugin.name]\n\n    def getRegisteredNodePlugins(self) -> dict[str: NodePlugin]:\n        \"\"\"\n        Return a dictionary containing all the registered NodePlugins, with\n        {key, value} = {name, NodePlugin}.\n        \"\"\"\n        return self._nodePlugins\n\n    def getRegisteredNodePlugin(self, name: str) -> NodePlugin:\n        \"\"\"\n        Return the NodePlugin object that has been registered under the name \"name\" if it exists.\n\n        Args:\n            name: the name of the NodePlugin used for its registration.\n\n        Returns:\n            NodePlugin | None: the loaded NodePlugin object if it exists, None otherwise.\n        \"\"\"\n        if self.isRegistered(name):\n            return self._nodePlugins[name]\n        return None\n\n    def registerNode(self, nodePlugin: NodePlugin):\n        \"\"\"\n        Register a node plugin. A registered node plugin will become instantiable.\n        If it is already registered, or if there is an issue with the node description,\n        the node plugin will not be registered and its status will be updated.\n\n        Args:\n            nodePlugin: the node plugin to register.\n        \"\"\"\n        name = nodePlugin.nodeDescriptor.__name__\n        if not self.isRegistered(name) and nodePlugin.status not in (NodePluginStatus.DESC_ERROR,\n                                                                     NodePluginStatus.ERROR):\n            try:\n                self._nodePlugins[name] = nodePlugin\n                nodePlugin.status = NodePluginStatus.LOADED\n            except Exception as exc:\n                logging.error(f\"NodePlugin {name} could not be loaded: {exc}\")\n                nodePlugin.status = NodePluginStatus.LOADING_ERROR\n\n    def unregisterNode(self, nodePlugin: NodePlugin):\n        \"\"\"\n        Unregister a node plugin. When unregistered, a node plugin cannot be instantiated anymore.\n        If it is not registered already, nothing happens.\n\n        Args:\n            nodePlugin: the node plugin to unregister.\n        \"\"\"\n        name = nodePlugin.nodeDescriptor.__name__\n        if self.isRegistered(name):\n            if nodePlugin.status != NodePluginStatus.LOADED:\n                logging.warning(f\"NodePlugin {name} is registered but is not correctly loaded.\")\n            else:\n                nodePlugin.status = NodePluginStatus.NOT_LOADED\n            del self._nodePlugins[name]\n"
  },
  {
    "path": "meshroom/core/stats.py",
    "content": "from collections import defaultdict\nimport os\nimport platform\nimport time\nimport threading\nimport xml.etree.ElementTree as ET\n\nimport subprocess\nimport logging\nimport psutil\n\n\ndef bytes2human(n):\n    \"\"\"\n    >>> bytes2human(10000)\n    '9.8 K/s'\n    >>> bytes2human(100001221)\n    '95.4 M/s'\n    \"\"\"\n    symbols = ('K', 'M', 'G', 'T', 'P', 'E', 'Z', 'Y')\n    prefix = {}\n    for i, s in enumerate(symbols):\n        prefix[s] = 1 << (i + 1) * 10\n    for s in reversed(symbols):\n        if n >= prefix[s]:\n            value = float(n) / prefix[s]\n            return f'{value:.2f} {s}'\n    return f'{n:.2f} B'\n\n\nclass ComputerStatistics:\n    def __init__(self):\n        self.nbCores = 0\n        self.cpuFreq = 0\n        self.ramTotal = 0\n        self.ramAvailable = 0  # GB\n        self.vramAvailable = 0  # GB\n        self.swapAvailable = 0\n        self.gpuMemoryTotal = 0\n        self.gpuName = ''\n        self.curves = defaultdict(list)\n        self.nvidia_smi = None\n        self._isInit = False\n\n    def initOnFirstTime(self):\n        if self._isInit:\n            return\n        self._isInit = True\n\n        self.cpuFreq = psutil.cpu_freq().max\n        self.ramTotal = psutil.virtual_memory().total / (1024*1024*1024)\n\n        if platform.system() == \"Windows\":\n            import shutil\n            # If the platform is Windows and nvidia-smi\n            self.nvidia_smi = shutil.which('nvidia-smi')\n            if self.nvidia_smi is None:\n                # Could not be found from the environment path,\n                # try to find it from system drive with default installation path\n                default_nvidia_smi = f\"{os.environ['systemdrive']}\\\\Program Files\\\\NVIDIA Corporation\\\\NVSMI\\\\nvidia-smi.exe\"\n                if os.path.isfile(default_nvidia_smi):\n                    self.nvidia_smi = default_nvidia_smi\n        else:\n            self.nvidia_smi = \"nvidia-smi\"\n\n    def _addKV(self, k, v):\n        if isinstance(v, tuple):\n            for ki, vi in v._asdict().items():\n                self._addKV(k + '.' + ki, vi)\n        elif isinstance(v, list):\n            for ki, vi in enumerate(v):\n                self._addKV(k + '.' + str(ki), vi)\n        else:\n            self.curves[k].append(v)\n\n    def update(self):\n        try:\n            self.initOnFirstTime()\n            # Interval=None => non-blocking (percentage since last call)\n            self._addKV('cpuUsage', psutil.cpu_percent(percpu=True))\n            self._addKV('ramUsage', psutil.virtual_memory().percent)\n            self._addKV('swapUsage', psutil.swap_memory().percent)\n            self._addKV('vramUsage', 0)\n            self._addKV('ioCounters', psutil.disk_io_counters())\n            self.updateGpu()\n        except Exception as exc:\n            logging.debug(f'Failed to get statistics: \"{exc}\".')\n\n    def updateGpu(self):\n        if not self.nvidia_smi:\n            return\n        try:\n            p = subprocess.Popen([self.nvidia_smi, \"-q\", \"-x\"], stdout=subprocess.PIPE, stderr=subprocess.PIPE)\n            xmlGpu, stdError = p.communicate(timeout=10)  # 10 seconds\n\n            smiTree = ET.fromstring(xmlGpu)\n            gpuTree = smiTree.find('gpu')\n\n            try:\n                self.gpuName = gpuTree.find('product_name').text\n            except Exception as exc:\n                logging.debug(f'Failed to get gpuName: \"{exc}\".')\n                pass\n            try:\n                gpuMemoryUsed = gpuTree.find('fb_memory_usage').find('used').text.split(\" \")[0]\n                self._addKV('gpuMemoryUsed', gpuMemoryUsed)\n            except Exception as exc:\n                logging.debug(f'Failed to get gpuMemoryUsed: \"{exc}\".')\n                pass\n            try:\n                self.gpuMemoryTotal = gpuTree.find('fb_memory_usage').find('total').text.split(\" \")[0]\n            except Exception as exc:\n                logging.debug(f'Failed to get gpuMemoryTotal: \"{exc}\".')\n                pass\n            try:\n                gpuUsed = gpuTree.find('utilization').find('gpu_util').text.split(\" \")[0]\n                self._addKV('gpuUsed', gpuUsed)\n            except Exception as exc:\n                logging.debug(f'Failed to get gpuUsed: \"{exc}\".')\n                pass\n            try:\n                gpuTemperature = gpuTree.find('temperature').find('gpu_temp').text.split(\" \")[0]\n                self._addKV('gpuTemperature', gpuTemperature)\n            except Exception as exc:\n                logging.debug(f'Failed to get gpuTemperature: \"{exc}\".')\n                pass\n        except subprocess.TimeoutExpired as exp:\n            logging.debug(f'Timeout when retrieving information from nvidia_smi: \"{exp}\".')\n            p.kill()\n            outs, errs = p.communicate()\n            return\n        except Exception as exc:\n            logging.debug(f'Failed to get information from nvidia_smi: \"{exc}\".')\n            return\n\n    def toDict(self):\n        return self.__dict__\n\n    def fromDict(self, d):\n        for k, v in d.items():\n            setattr(self, k, v)\n\n\nclass ProcStatistics:\n    staticKeys = [\n        'pid',\n        'nice',\n        'cpu_times',\n        'create_time',\n        'environ',\n        'ionice',\n        # 'gids',\n        # 'uids',\n        'cpu_num',\n        'cwd',\n        'cmdline',\n        'cpu_affinity',\n        # 'ppid',\n        # 'name',\n        # 'exe',\n        # 'terminal',\n        'username',\n        ]\n    dynamicKeys = [\n        # 'memory_full_info',\n        # 'connections',\n        'cpu_percent',\n        # 'open_files',\n        'memory_info',\n        'memory_percent',\n        'threads',\n        'num_threads',\n        # 'memory_maps',\n        'status',\n        # 'num_fds', # The number of file descriptors currently opened by this process (non cumulative) - N/A on Windows\n        # 'io_counters', # The number and bytes read/write by the process - N/A on some platforms\n        'num_ctx_switches',\n        ]\n\n    def __init__(self):\n        self.iterIndex = 0\n        self.lastIterIndexWithFiles = -1\n        self.duration = 0  # computation time set at the end of the execution\n        self.curves = defaultdict(list)\n        self.openFiles = {}\n\n    def _addKV(self, k, v):\n        if isinstance(v, tuple):\n            for ki, vi in v._asdict().items():\n                self._addKV(k + '.' + ki, vi)\n        elif isinstance(v, list):\n            for ki, vi in enumerate(v):\n                self._addKV(k + '.' + str(ki), vi)\n        else:\n            self.curves[k].append(v)\n\n    def update(self, proc):\n        '''\n        proc: psutil.Process object\n        '''\n        data = proc.as_dict(self.dynamicKeys)\n        for k, v in data.items():\n            self._addKV(k, v)\n\n        # Note: Do not collect stats about open files for now,\n        #        as there is bug in psutil-5.7.2 on Windows which crashes the application.\n        #        https://github.com/giampaolo/psutil/issues/1763\n        #\n        # files = [f.path for f in proc.open_files()]\n        # if self.lastIterIndexWithFiles != -1:\n        #     if set(files) != set(self.openFiles[self.lastIterIndexWithFiles]):\n        #         self.openFiles[self.iterIndex] = files\n        #         self.lastIterIndexWithFiles = self.iterIndex\n        # elif files:\n        #     self.openFiles[self.iterIndex] = files\n        #     self.lastIterIndexWithFiles = self.iterIndex\n        self.iterIndex += 1\n\n    def toDict(self):\n        return {\n            'duration': self.duration,\n            'curves': self.curves,\n            'openFiles': self.openFiles,\n        }\n\n    def fromDict(self, d):\n        self.duration = d.get('duration', 0)\n        self.curves = d.get('curves', defaultdict(list))\n        self.openFiles = d.get('openFiles', {})\n\n\nclass Statistics:\n    \"\"\"\n    \"\"\"\n    fileVersion = 2.0\n\n    def __init__(self, maxPoints=100):\n        self.computer = ComputerStatistics()\n        self.process = ProcStatistics()\n        self.times = []\n        self.interval = 1  # refresh interval in seconds\n        self.maxPoints = maxPoints  # maximum number of points to keep\n\n    def _filterDataPoints(self, keepEveryN):\n        \"\"\"\n        Filter data points to keep every Nth point.\n        \"\"\"\n        # Filter times\n        self.times = self.times[::keepEveryN]\n\n        # Filter computer curves\n        for key in self.computer.curves:\n            self.computer.curves[key] = self.computer.curves[key][::keepEveryN]\n\n        # Filter process curves\n        for key in self.process.curves:\n            self.process.curves[key] = self.process.curves[key][::keepEveryN]\n\n    def update(self, proc):\n        '''\n        proc: psutil.Process object\n        '''\n        if proc is None or not proc.is_running():\n            return False\n        self.times.append(time.time())\n        self.computer.update()\n        self.process.update(proc)\n\n        # Check if we exceeded max points and need to adjust interval\n        if len(self.times) > self.maxPoints:\n            # Calculate new interval (double it)\n            newInterval = self.interval * 2\n            # Filter existing data to keep every other point\n            self._filterDataPoints(2)\n            # Update interval\n            self.interval = newInterval\n            logging.debug(f'Statistics: Increased interval to {self.interval}s to maintain max {self.maxPoints} points')\n\n        return True\n\n    def toDict(self):\n        return {\n            'fileVersion': self.fileVersion,\n            'computer': self.computer.toDict(),\n            'process': self.process.toDict(),\n            'times': self.times,\n            'interval': self.interval,\n            'maxPoints': self.maxPoints,\n        }\n\n    def fromDict(self, d):\n        version = d.get('fileVersion', 0.0)\n        if version != self.fileVersion:\n            logging.debug(f'Statistics: file version was {version} and the current version is {self.fileVersion}.')\n        self.computer = ComputerStatistics()\n        self.process = ProcStatistics()\n        self.times = []\n        self.interval = d.get('interval', 1)\n        self.maxPoints = d.get('maxPoints', 100)\n        try:\n            self.computer.fromDict(d.get('computer', {}))\n        except Exception as exc:\n            logging.debug(f'Failed while loading statistics: computer: \"{exc}\".')\n        try:\n            self.process.fromDict(d.get('process', {}))\n        except Exception as exc:\n            logging.debug(f'Failed while loading statistics: process: \"{exc}\".')\n        try:\n            self.times = d.get('times', [])\n        except Exception as exc:\n            logging.debug(f'Failed while loading statistics: times: \"{exc}\".')\n\n\nbytesPerGiga = 1024. * 1024. * 1024.\n\n\nclass StatisticsThread(threading.Thread):\n    def __init__(self, chunk):\n        threading.Thread.__init__(self)\n        self.chunk = chunk\n        self.proc = psutil.Process()  # by default current process pid\n        self.statistics = chunk.statistics\n        self._stopFlag = threading.Event()\n\n    def updateStats(self):\n        self.lastTime = time.time()\n        if self.chunk.statistics.update(self.proc):\n            self.chunk.saveStatistics()\n\n    def run(self):\n        try:\n            while True:\n                self.updateStats()\n                if self._stopFlag.wait(self.statistics.interval):\n                    # stopFlag has been set\n                    # update stats one last time and exit main loop\n                    if self.proc.is_running():\n                        self.updateStats()\n                    return\n        except (KeyboardInterrupt, SystemError, GeneratorExit, psutil.NoSuchProcess):\n            pass\n\n    def stopRequest(self):\n        \"\"\" Request the thread to exit as soon as possible. \"\"\"\n        self._stopFlag.set()\n"
  },
  {
    "path": "meshroom/core/submitter.py",
    "content": "#!/usr/bin/env python\n\nimport sys\nimport logging\nimport operator\n\nfrom enum import IntFlag, auto\nfrom typing import Optional\nfrom itertools import accumulate\n\nimport meshroom\nfrom meshroom.common import BaseObject, Property\n\n\nlogger = logging.getLogger(\"Submitter\")\nlogger.setLevel(logging.INFO)\n\n\nclass SubmitterOptionsEnum(IntFlag):\n    RETRIEVE = auto()       # Can retrieve job (read job tasks, ...)\n    INTERRUPT_JOB = auto()  # Can interrupt\n    RESUME_JOB = auto()     # Can resume after interruption\n    EDIT_TASKS = auto()     # Can edit tasks\n    ATTACH_JOB = auto()     # Can attach a job that will execute after another job\n\n    @classmethod\n    def get(cls, option):\n        if isinstance(option, str):\n            # Try to cast to SubmitterOptionsEnum\n            option = getattr(cls, option.upper(), None)\n        elif isinstance(option, int):\n            option = cls(option)\n        if isinstance(option, cls):\n            return option\n        return 0\n\n# SubmitterOptionsEnum.ALL = SubmitterOptionsEnum(SubmitterOptionsEnum._all_bits_)  # _all_bits_ -> py 3.11\nSubmitterOptionsEnum.ALL = list(accumulate(SubmitterOptionsEnum, operator.__ior__))[-1]\n\n\nclass SubmitterOptions:\n    def __init__(self, *args):\n        self._options = 0\n        for option in args:\n            self.addOption(option)\n\n    def addOption(self, option):\n        option = SubmitterOptionsEnum.get(option)\n        self._options |= option\n\n    def includes(self, option):\n        option = SubmitterOptionsEnum.get(option)\n        return self._options & option > 0\n\n    def __iter__(self):\n        for o in SubmitterOptionsEnum:\n            if self.includes(o):\n                yield(o)\n\n    def __repr__(self):\n        if self._options == 0:\n            return f\"<SubmitterOptions NONE>\"\n        if self._options == SubmitterOptionsEnum.ALL:\n            return f\"<SubmitterOptions ALL>\"\n        return f\"<SubmitterOptions {'|'.join([o.name for o in self])}>\"\n\n\nclass BaseSubmittedJob:\n    \"\"\"\n    Interface to manipulate the job via Meshroom\n    \"\"\"\n\n    def __init__(self, jobId, submitter):\n        self.jid = jobId\n        self.submitterName: str = submitter._name\n        self.submitterOptions: SubmitterOptions = submitter._options\n\n    def __repr__(self):\n        return f\"<{self.__class__.__name__} {self.jid}>\"\n\n    # Task actions\n    # For all methods if If iteration is -1 then it kills all the tasks for the given node\n\n    def stopChunkTask(self, node, iteration):\n        \"\"\" This will kill one task.\n        If iteration is -1 then it kills all the tasks for the given node\n        \"\"\"\n        if self.submitterOptions.includes(SubmitterOptionsEnum.INTERRUPT_JOB):\n            raise NotImplementedError(f\"'stopChunkTask' method must be implemented in subclasses\")\n        else:\n            raise RuntimeError(f\"Submitter {self.__class__.__name__} cannot interrupt the job\")\n\n    def skipChunkTask(self, node, iteration):\n        \"\"\" This will kill one task \"\"\"\n        if self.submitterOptions.includes(SubmitterOptionsEnum.INTERRUPT_JOB):\n            raise NotImplementedError(\"'skipChunkTask' method must be implemented in subclasses\")\n        else:\n            raise RuntimeError(f\"Submitter {self.__class__.__name__} cannot interrupt the job\")\n\n    def restartChunkTask(self, node, iteration):\n        \"\"\" This will kill one task \"\"\"\n        if self.submitterOptions.includes(SubmitterOptionsEnum.RESUME_JOB):\n            raise NotImplementedError(\"'restartChunkTask' method must be implemented in subclasses\")\n        else:\n            raise RuntimeError(f\"Submitter {self.__class__.__name__} cannot interrupt the job\")\n\n    # Job actions\n\n    def pauseJob(self):\n        \"\"\" This will pause the job : new tasks will not be processed \"\"\"\n        if self.submitterOptions.includes(SubmitterOptionsEnum.INTERRUPT_JOB):\n            raise NotImplementedError(\"'pauseJob' method must be implemented in subclasses\")\n        else:\n            raise RuntimeError(f\"Submitter {self.__class__.__name__} cannot interrupt the job\")\n\n    def resumeJob(self):\n        \"\"\" This will unpause the job \"\"\"\n        if self.submitterOptions.includes(SubmitterOptionsEnum.RESUME_JOB):\n            raise NotImplementedError(\"'resumeJob' method must be implemented in subclasses\")\n        else:\n            raise RuntimeError(f\"Submitter {self.__class__.__name__} cannot interrupt the job\")\n\n    def interruptJob(self):\n        \"\"\" This will interrupt the job (and kill running tasks) \"\"\"\n        if self.submitterOptions.includes(SubmitterOptionsEnum.INTERRUPT_JOB):\n            raise NotImplementedError(\"'interruptJob' method must be implemented in subclasses\")\n        else:\n            raise RuntimeError(f\"Submitter {self.__class__.__name__} cannot interrupt the job\")\n\n    def restartErrorTasks(self):\n        if self.submitterOptions.includes(SubmitterOptionsEnum.RESUME_JOB):\n            raise NotImplementedError(\"'restartErrorTasks' method must be implemented in subclasses\")\n        else:\n            raise RuntimeError(f\"Submitter {self.__class__.__name__} cannot restart the job\")\n\n\nclass JobManager(BaseObject):\n    \"\"\" Central manager for all jobs \"\"\"\n\n    def __init__(self):\n        super().__init__()\n        self._jobs = {}  # jobId -> BaseSubmittedJob\n        self._nodeToJob = {}  # node uid -> Job\n\n    def addJob(self, job: BaseSubmittedJob, nodes):\n        jid = job.jid\n        if jid not in self._jobs:\n            self._jobs[jid] = job\n        for node in nodes:\n            nodeUid = node._uid\n            self._nodeToJob[nodeUid] = jid\n            # Update the node status file to store the job ID\n            node.setJobId(jid, job.submitterName)\n\n    def resetNodeJob(self, node):\n        node._nodeStatus.jobInfo = {}\n        if node._uid in self._nodeToJob:\n            del self._nodeToJob[node._uid]\n\n    def getJob(self, jobId: str) -> Optional[BaseSubmittedJob]:\n        return self._jobs.get(jobId)\n\n    def removeJob(self, jobId: str):\n        with self._lock:\n            if jobId in self._jobs:\n                del self._jobs[jobId]\n\n    def getNodeJob(self, node):\n        nodeUid = node._uid\n        jobId = self._nodeToJob.get(nodeUid)\n        if jobId:\n            return self.getJob(jobId)\n        return None\n\n    def getAllNodesUIDForJob(self, job):\n        return [n for n, j in self._nodeToJob.items() if j == job.jid]\n\n    def retreiveJob(self, submitter, jid) -> Optional[BaseSubmittedJob]:\n        if not submitter._options.includes(SubmitterOptionsEnum.RETRIEVE):\n            return None\n        job = submitter.retrieveJob(jid)\n        return job\n\n\n# Global instance that manages submitted jobs\njobManager = JobManager()\n\n\nclass BaseSubmitter(BaseObject):\n    _options: SubmitterOptions = SubmitterOptions()\n    _name = \"\"\n\n    def __init__(self, parent=None):\n        if not self._name:\n            raise ValueError(\"Could not register submitter without name\")\n        super().__init__(parent)\n        logger.info(f\"Registered submitter {self._name} (options={self._options})\")\n\n    @property\n    def name(self):\n        return self._name\n\n    def createJob(self, nodes, edges, filepath, submitLabel=\"{projectName}\"):\n        \"\"\" Submit the given graph\n         Returns:\n             bool: whether the submission succeeded\n        \"\"\"\n        raise NotImplementedError(\"'createJob' method must be implemented in subclasses\")\n\n    def createChunkTask(self, node, graphFile, **kwargs):\n        if self._options.includes(SubmitterOptionsEnum.RESUME_JOB):\n            raise NotImplementedError(\"'createChunkTask' method must be implemented in subclasses\")\n        else:\n            raise RuntimeError(f\"Submitter {self.name} cannot edit the job\")\n\n    def retrieveJob(self, jobId) -> BaseSubmittedJob:\n        raise NotImplementedError(\"'retrieveJob' method must be implemented in subclasses\")\n\n    def submit(self, nodes, edges, filepath, submitLabel=\"{projectName}\") -> BaseSubmittedJob:\n        \"\"\" Submit the given graph\n         Returns:\n             bool: whether the submission succeeded\n        \"\"\"\n        job = self.createJob(nodes, edges, filepath, submitLabel)\n        if not job:\n            # Failed to create the job\n            return None\n        return job\n\n    @staticmethod\n    def killRunningJob():\n        \"\"\" Sometimes farms are automatically re-trying job once in case it was\n        killed by a user who does not want their machine to be used. Unfortunately this\n        means jobs will be launched twice even if they failed for a good reason.\n        This function can be used to make sure the current job will not restart\n        Note : the ERROR_NO_RETRY itself will not do anything. This function must be\n        implemented on a case-by-case for each possible farm system\n        \"\"\"\n        sys.exit(meshroom.MeshroomExitStatus.ERROR_NO_RETRY)\n\n    name = Property(str, lambda self: self._name, constant=True)\n"
  },
  {
    "path": "meshroom/core/taskManager.py",
    "content": "import traceback\nimport logging\nfrom threading import Thread\nfrom PySide6.QtCore import QThread, QEventLoop, QTimer\nfrom enum import Enum\n\nimport meshroom\nfrom meshroom.common import BaseObject, DictModel, Property, Signal, Slot\nfrom meshroom.core.node import Node, Status\nfrom meshroom.core.graph import Graph\nfrom meshroom.core.submitter import jobManager, BaseSubmittedJob\nimport meshroom.core.graph\n\n\nclass State(Enum):\n    \"\"\"\n    State of the Thread that is computing nodes\n    \"\"\"\n    IDLE = 0\n    RUNNING = 1\n    STOPPED = 2\n    DEAD = 3\n    ERROR = 4\n\n\nclass TaskThread(QThread):\n    \"\"\"\n    A thread with a pile of nodes to compute\n    \"\"\"\n    def __init__(self, manager):\n        QThread.__init__(self)\n        self._state = State.IDLE\n        self._manager = manager\n        self.forceCompute = False\n        # Connect to manager's chunk creation handler\n        self.createChunksSignal.connect(manager.createChunks)\n\n    def isRunning(self):\n        return self._state == State.RUNNING\n\n    def waitForChunkCreation(self, node):\n        if node._chunksCreated:\n            return True\n\n        loop = QEventLoop()\n\n        # A timer is used to make sure we do not indefinitely block the taskManager\n        timer = QTimer()\n        timer.timeout.connect(loop.quit)\n        timer.setSingleShot(True)\n        timer.start(1*60*1000)  # 1 min timeout\n\n        # Connect to completion signal\n        def onChunksCreated(createdNode):\n            if createdNode == node:\n                loop.quit()\n\n        self._manager.chunksCreated.connect(onChunksCreated)\n\n        try:\n            # Start the event loop - will block until signal or timeout\n            loop.exec()\n            if not node._chunksCreated:\n                logging.error(f\"Timeout or failure creating chunks for {node.name}\")\n                return False\n            return True\n        finally:\n            self._manager.chunksCreated.disconnect(onChunksCreated)\n            timer.stop()\n\n    def run(self):\n        \"\"\" Consume compute tasks. \"\"\"\n        self._state = State.RUNNING\n        stopAndRestart = False\n\n        for nId, node in enumerate(self._manager._nodesToProcess):\n            if node not in self._manager._nodesToProcess:\n                # Node was removed from the processing list\n                continue\n\n            # Skip already finished/running nodes or nodes in compatibility mode\n            if node.isFinishedOrRunning() or node.isCompatibilityNode:\n                continue\n\n            # Request chunk creation if not already done\n            if not node._chunksCreated:\n                self.createChunksSignal.emit(node)\n                # Wait for chunk creation to complete\n                if not self.waitForChunkCreation(node):\n                    logging.error(f\"Failed to create chunks for {node.name}, stopping the process\")\n                    break\n            else:\n                node._updateNodeSize()\n\n            # if a node does not exist anymore, node.chunks becomes a PySide property\n            try:\n                multiChunks = len(node.chunks) > 1\n            except TypeError:\n                continue\n\n            node.preprocess()\n            for cId, chunk in enumerate(node.chunks):\n                if chunk.isFinishedOrRunning() or not self.isRunning():\n                    continue\n\n                if self._manager.isChunkCancelled(chunk):\n                    continue\n\n                _nodeName, _node, _nbNodes = node.nodeType, nId+1, len(self._manager._nodesToProcess)\n\n                if multiChunks:\n                    _chunk, _nbChunks = cId+1, len(node.chunks)\n                    logging.info(f\"[{_node}/{_nbNodes}]({_chunk}/{_nbChunks}) {_nodeName}\")\n                else:\n                    logging.info(f\"[{_node}/{_nbNodes}] {_nodeName}\")\n                try:\n                    chunk.process(self.forceCompute)\n                except Exception as exc:\n                    if chunk.isStopped():\n                        stopAndRestart = True\n                        break\n                    else:\n                        logging.error(f\"Error on node computation: {exc}\")\n                        nodesToRemove, _ = self._manager._graph.dfsOnDiscover(startNodes=[node], reverse=True)\n                        # remove following nodes from the task queue\n                        for n in nodesToRemove[1:]:  # exclude current node\n                            try:\n                                self._manager._nodesToProcess.remove(n)\n                            except ValueError:\n                                # Node already removed (for instance a global clear of _nodesToProcess)\n                                pass\n                            n.clearSubmittedChunks()\n            node.postprocess()\n\n            if stopAndRestart:\n                break\n\n        if stopAndRestart:\n            self._state = State.STOPPED\n            self._manager.restartRequested.emit()\n        else:\n            self._manager._nodesToProcess = []\n            self._state = State.DEAD\n\n    # Signals and properties\n    createChunksSignal = Signal(BaseObject)\n\n\nclass TaskManager(BaseObject):\n    \"\"\"\n    Manage graph - local and external - computation tasks.\n    \"\"\"\n    def __init__(self, parent: BaseObject = None):\n        super().__init__(parent)\n        self._graph = None\n        self._nodes = DictModel(keyAttrName='_name', parent=self)\n        self._nodesToProcess = []\n        self._cancelledChunks = []\n        self._nodesExtern = []\n        # internal thread in which local tasks are executed\n        self._thread = TaskThread(self)\n\n        self._blockRestart = False\n        self.restartRequested.connect(self.restart)\n\n    def join(self):\n        self._thread.wait()\n        self._cancelledChunks = []\n\n    @Slot(BaseObject)\n    def createChunks(self, node: Node):\n        \"\"\" Create chunks on main process \"\"\"\n        try:\n            if not node._chunksCreated:\n                node.createChunks()\n            # Prepare all chunks\n            node.initStatusOnCompute()\n            self.chunksCreated.emit(node)\n        except Exception as e:\n            logging.error(f\"Failed to create chunks for {node.name}: {e}\")\n            self.chunksCreated.emit(node)  # Still emit to unblock waiting thread\n\n    def isChunkCancelled(self, chunk):\n        for i, ch in enumerate(self._cancelledChunks):\n            if ch == chunk:\n                del self._cancelledChunks[i]\n                return True\n        return False\n\n    def requestBlockRestart(self):\n        \"\"\"\n        Block computing.\n        Note: should only be used to completely stop computing.\n        \"\"\"\n        self._blockRestart = True\n\n    def blockRestart(self):\n        \"\"\" Avoid the automatic restart of computing. \"\"\"\n        for node in self._nodesToProcess:\n            chunkCount = 0\n            for chunk in node.chunks:\n                if chunk.status.status in (Status.SUBMITTED, Status.ERROR):\n                    chunk.upgradeStatusTo(Status.NONE)\n                    chunkCount += 1\n            if chunkCount == len(node.chunks):\n                self.removeNode(node, displayList=True)\n\n        self._blockRestart = False\n        self._nodesToProcess = []\n        self._cancelledChunks = []\n        self._thread._state = State.DEAD\n\n    @Slot()\n    def pauseProcess(self):\n        if self._thread.isRunning():\n            self.join()\n        for node in self._nodesToProcess:\n            if node.getGlobalStatus() == Status.STOPPED:\n                # Remove node from the computing list\n                self.removeNode(node, displayList=False, processList=True)\n\n                # Remove output nodes from display and computing lists\n                outputNodes = node.getOutputNodes(recursive=True, dependenciesOnly=True)\n                for n in outputNodes:\n                    if n.getGlobalStatus() in (Status.ERROR, Status.SUBMITTED):\n                        n.upgradeStatusTo(Status.NONE)\n                        self.removeNode(n, displayList=True, processList=True)\n\n    @Slot()\n    def restart(self):\n        \"\"\"\n        Restart computing when thread has been stopped.\n        Note: this is done like this to avoid app freezing.\n        \"\"\"\n        # Make sure to wait the end of the current thread\n        if self._thread.isRunning():\n            self.join()\n\n        # Avoid restart if thread was globally stopped\n        if self._blockRestart:\n            self.blockRestart()\n            return\n\n        if self._thread._state != State.STOPPED:\n            return\n\n        for node in self._nodesToProcess:\n            if node.getGlobalStatus() == Status.STOPPED:\n                # Remove node from the computing list\n                self.removeNode(node, displayList=False, processList=True)\n\n                # Remove output nodes from display and computing lists\n                outputNodes = node.getOutputNodes(recursive=True, dependenciesOnly=True)\n                for n in outputNodes:\n                    if n.getGlobalStatus() in (Status.ERROR, Status.SUBMITTED):\n                        n.upgradeStatusTo(Status.NONE)\n                        self.removeNode(n, displayList=True, processList=True)\n\n        # Start a new thread with the remaining nodes to compute\n        self._thread = TaskThread(self)\n        self._thread.start()\n\n    def compute(self, graph: Graph = None, toNodes: list[Node] = None, forceCompute: bool = False, forceStatus: bool = False):\n        \"\"\"\n        Start graph computation, from root nodes to leaves - or nodes in 'toNodes' if specified.\n        Computation tasks (NodeChunk) happen in a separate thread (see TaskThread).\n\n        :param graph: the graph to consider.\n        :param toNodes: specific leaves, all graph leaves if None.\n        :param forceCompute: force the computation despite nodes status.\n        :param forceStatus: force the computation even if some nodes are submitted externally.\n        \"\"\"\n\n        self._graph = graph\n\n        self.updateNodes()\n        self._cancelledChunks = []\n\n        if forceCompute:\n            nodes, edges = graph.dfsOnFinish(startNodes=toNodes)\n            self.checkCompatibilityNodes(graph, nodes, \"COMPUTATION\")  # name of the context is important for QML\n            self.checkDuplicates(nodes, \"COMPUTATION\")  # name of the context is important for QML\n        else:\n            # Check dependencies of toNodes\n            if not toNodes:\n                toNodes = graph.getLeafNodes(dependenciesOnly=True)\n            toNodes = list(toNodes)\n            toNodes = [node for node in toNodes if not node.isBackdropNode]\n            allReady = self.checkNodesDependencies(graph, toNodes, \"COMPUTATION\")\n\n            # At this point, toNodes is a list\n            # If it is empty, we raise an error to avoid passing through dfsToProcess\n            if not toNodes:\n                self.raiseImpossibleProcess(\"COMPUTATION\")\n\n            nodes, edges = graph.dfsToProcess(startNodes=toNodes)\n            if not nodes:\n                logging.warning('Nothing to compute')\n                return\n            self.checkCompatibilityNodes(graph, nodes, \"COMPUTATION\")  # name of the context is important for QML\n            self.checkDuplicates(nodes, \"COMPUTATION\")  # name of the context is important for QML\n\n            nodes = [node for node in nodes if not self.contains(node)]  # be sure to avoid non-real conflicts\n            nodes = list(set(nodes))\n            nodes = sorted(nodes, key=lambda x: x.depth)\n\n            chunksInConflict = self.getAlreadySubmittedChunks(nodes)\n\n            if chunksInConflict:\n                chunksStatus = {chunk.status.status.name for chunk in chunksInConflict}\n                chunksName = [node.name for node in chunksInConflict]\n                # Warning: Syntax and terms are parsed on QML side to recognize the error\n                # Syntax : [Context] ErrorType: ErrorMessage\n                msg = f'[COMPUTATION] Already Submitted:\\nWARNING - Some nodes are already submitted with status: ' \\\n                      f'{\", \".join(chunksStatus)}\\nNodes: {\", \".join(chunksName)}'\n\n                if forceStatus:\n                    logging.warning(msg)\n                else:\n                    raise RuntimeError(msg)\n\n        for node in nodes:\n            node.destroyed.connect(lambda obj=None, name=node.name: self.onNodeDestroyed(obj, name))\n            node.initStatusOnCompute(forceCompute)\n\n        self._nodes.update(nodes)\n        self._nodesToProcess.extend(nodes)\n\n        if self._thread._state == State.IDLE:\n            self._thread.start()\n        elif self._thread._state in (State.DEAD, State.ERROR):\n            self._thread = TaskThread(self)\n            self._thread.start()\n\n        # At the end because it raises a WarningError but should not stop processing\n        if not allReady:\n            self.raiseDependenciesMessage(\"COMPUTATION\")\n\n    def onNodeDestroyed(self, obj, name):\n        \"\"\"\n        Remove node from the taskmanager when it is destroyed in the graph\n        :param obj:\n        :param name:\n        :return:\n        \"\"\"\n        if name in self._nodes.keys():\n            self._nodes.pop(name)\n\n    def contains(self, node):\n        return node in self._nodes.values()\n\n    def containsNodeName(self, name):\n        \"\"\" Check if a node with the argument name belongs to the display list. \"\"\"\n        if name in self._nodes.keys():\n            return True\n        return False\n\n    def removeNode(self, node, displayList=True, processList=False, externList=False):\n        \"\"\" Remove node from the Task Manager.\n\n            Args:\n                node (Node): node to remove.\n                displayList (bool): remove from the display list.\n                processList (bool): remove from the nodesToProcess list.\n                externList (bool): remove from the nodesExtern list.\n        \"\"\"\n        if displayList and self._nodes.contains(node):\n            self._nodes.pop(node.name)\n        if processList and node in self._nodesToProcess:\n            self._nodesToProcess.remove(node)\n        if externList and node in self._nodesExtern:\n            self._nodesExtern.remove(node)\n\n    def clear(self):\n        \"\"\"\n        Remove all the nodes from the taskmanager\n        :return:\n        \"\"\"\n        self._nodes.clear()\n        self._nodesExtern = []\n        self._nodesToProcess = []\n\n    def updateNodes(self):\n        \"\"\"\n        Update task manager nodes lists by checking the nodes status.\n        \"\"\"\n        self._nodesExtern = [node for node in self._nodesExtern if node.isExtern() and node.isAlreadySubmitted()]\n        newNodes = [node for node in self._nodes if node.isAlreadySubmitted()]\n        if len(newNodes) != len(self._nodes):\n            self._nodes.clear()\n            self._nodes.update(newNodes)\n\n    def update(self, graph):\n        \"\"\"\n        Add all the nodes that are being rendered in a renderfarm to the taskmanager when new graph is loaded\n        :param graph:\n        :return:\n        \"\"\"\n        for node in graph._nodes:\n            if node.isAlreadySubmitted() and node._chunks.size() > 0 and node.isExtern():\n                self._nodes.add(node)\n                self._nodesExtern.append(node)\n\n    def checkCompatibilityNodes(self, graph, nodes, context):\n        compatNodes = []\n        for node in nodes:\n            if node in graph._compatibilityNodes.values():\n                compatNodes.append(node.nameToLabel(node.name))\n        if compatNodes:\n            # Warning: Syntax and terms are parsed on QML side to recognize the error\n            # Syntax : [Context] ErrorType: ErrorMessage\n            raise RuntimeError(f\"[{context}] Compatibility Issue:\\n\"\n                               f\"Cannot compute because of these incompatible nodes:\\n\"\n                               f\"{sorted(compatNodes)}\")\n\n    def checkDuplicates(self, nodesToProcess, context):\n        for node in nodesToProcess:\n            for duplicate in node.duplicates:\n                if duplicate in nodesToProcess:\n                    # Warning: Syntax and terms are parsed on QML side to recognize the error\n                    # Syntax : [Context] ErrorType: ErrorMessage\n                    raise RuntimeError(f\"[{context}] Duplicates Issue:\\n\"\n                                       f\"Cannot compute because there are some duplicate nodes to process:\\n\\n\"\n                                       f\"First match: '{node.nameToLabel(node.name)}' and '{node.nameToLabel(duplicate.name)}'\\n\\n\"\n                                       f\"There can be other duplicate nodes in the list. \"\n                                       f\"Please, check the graph and try again.\")\n\n    def checkNodesDependencies(self, graph, toNodes, context):\n        \"\"\"\n        Check dependencies of nodes to process.\n        Update toNodes with computable/submittable nodes only.\n\n        Returns:\n            bool: True if all the nodes can be processed. False otherwise.\n        \"\"\"\n        ready = []\n        computed = []\n        inputNodes = []\n        for node in toNodes:\n            if node.isInputNode:\n                inputNodes.append(node)\n            elif context == \"COMPUTATION\":\n                if graph.canComputeTopologically(node) and graph.canSubmitOrCompute(node) % 2 == 1:\n                    ready.append(node)\n                elif node.isComputed:\n                    computed.append(node)\n            elif context == \"SUBMITTING\":\n                if graph.canComputeTopologically(node) and graph.canSubmitOrCompute(node) > 1:\n                    ready.append(node)\n                elif node.isComputed:\n                    computed.append(node)\n            else:\n                raise ValueError(\"Argument 'context' must be: 'COMPUTATION' or 'SUBMITTING'\")\n\n        if len(ready) + len(computed) + len(inputNodes) != len(toNodes):\n            toNodes.clear()\n            toNodes.extend(ready)\n            return False\n\n        return True\n\n    def raiseDependenciesMessage(self, context):\n        # Warning: Syntax and terms are parsed on QML side to recognize the error\n        # Syntax : [Context] ErrorType: ErrorMessage\n        raise RuntimeWarning(f\"[{context}] Unresolved dependencies:\\n\"\n                             f\"Some nodes cannot be computed in LOCAL/submitted in EXTERN because of \"\n                             f\"unresolved dependencies.\\n\\n\"\n                             f\"Nodes which are ready will be processed.\")\n\n    def raiseImpossibleProcess(self, context):\n        # Warning: Syntax and terms are parsed on QML side to recognize the error\n        # Syntax : [Context] ErrorType: ErrorMessage\n        raise RuntimeError(f\"[{context}] Impossible Process:\\n\"\n                           f\"There is no node able to be processed.\")\n\n    def submit(self, graph, submitter=None, toNodes=None, submitLabel=\"{projectName}\"):\n        \"\"\"\n        Nodes are send to the renderfarm\n        :param graph:\n        :param submitter:\n        :param toNodes:\n        :return:\n        \"\"\"\n        # Ensure submitter is properly set\n        sub = None\n        if submitter:\n            sub = meshroom.core.submitters.get(submitter, None)\n        elif len(meshroom.core.submitters) >= 1:\n            # if only one submitter available use it\n            allSubmitters = meshroom.core.submitters.values()\n            sub = next(iter(allSubmitters))  # retrieve the first element\n        if sub is None:\n            # Warning: Syntax and terms are parsed on QML side to recognize the error\n            # Syntax : [Context] ErrorType: ErrorMessage\n            raise RuntimeError(f\"[SUBMITTING] Unknown Submitter:\\n\"\n                               f\"Unknown Submitter called '{submitter}'. \"\n                               f\"Available submitters are: '{str(meshroom.core.submitters.keys())}'.\")\n\n        # TODO : If possible with the submitter (ATTACH_JOB)\n\n        # Update task manager's lists\n        self.updateNodes()\n        graph.update()\n\n        # Check dependencies of toNodes\n        if not toNodes:\n            toNodes = graph.getLeafNodes(dependenciesOnly=True)\n        toNodes = list(toNodes)\n        toNodes = [node for node in toNodes if not node.isBackdropNode]\n        allReady = self.checkNodesDependencies(graph, toNodes, \"SUBMITTING\")\n\n        # At this point, toNodes is a list\n        # If it is empty, we raise an error to avoid passing through dfsToProcess\n        if not toNodes:\n            self.raiseImpossibleProcess(\"SUBMITTING\")\n\n        nodesToProcess, edgesToProcess = graph.dfsToProcess(startNodes=toNodes)\n        if not nodesToProcess:\n            logging.warning('Nothing to compute')\n            return\n        self.checkCompatibilityNodes(graph, nodesToProcess, \"SUBMITTING\")  # name of the context is important for QML\n        self.checkDuplicates(nodesToProcess, \"SUBMITTING\")  # name of the context is important for QML\n\n        # Update nodes status\n        for node in nodesToProcess:\n            node.destroyed.connect(lambda obj=None, name=node.name: self.onNodeDestroyed(obj, name))\n            node.initStatusOnSubmit()\n            jobManager.resetNodeJob(node)\n\n        graph.updateMonitoredFiles()\n\n        flowEdges = graph.flowEdges(startNodes=toNodes)\n        edgesToProcess = set(edgesToProcess).intersection(flowEdges)\n\n        logging.info(f\"Nodes to process: {nodesToProcess}\")\n        logging.info(f\"Edges to process: {edgesToProcess}\")\n\n        try:\n            res = sub.submit(nodesToProcess, edgesToProcess, graph.filepath, submitLabel=submitLabel)\n            if res:\n                if isinstance(res, BaseSubmittedJob):\n                    jobManager.addJob(res, nodesToProcess)\n            else:\n                for node in nodesToProcess:\n                    # TODO : Notify the node that there was an issue on submit\n                    pass\n            self._nodes.update(nodesToProcess)\n            self._nodesExtern.extend(nodesToProcess)\n\n            # At the end because it raises a WarningError but should not stop processing\n            if not allReady:\n                self.raiseDependenciesMessage(\"SUBMITTING\")\n        except Exception as exc:\n            logging.error(f\"Error on submit : {exc}\\n{traceback.format_exc()}\")\n\n    def submitFromFile(self, graphFile, submitter, toNode=None, submitLabel=\"{projectName}\"):\n        \"\"\"\n        Submit the given graph via the given submitter.\n        \"\"\"\n        graph = meshroom.core.graph.loadGraph(graphFile)\n        self.submit(graph, submitter, toNode, submitLabel=submitLabel)\n\n    def getAlreadySubmittedChunks(self, nodes):\n        \"\"\"\n        Check if nodes have already been submitted in another Meshroom instance.\n        :param nodes:\n        :return:\n        \"\"\"\n        out = []\n        for node in nodes:\n            for chunk in node.chunks:\n                # Already submitted/running chunks in another task manager\n                if chunk.isAlreadySubmitted() and not self.containsNodeName(chunk.statusNodeName):\n                    out.append(chunk)\n        return out\n\n    nodes = Property(BaseObject, lambda self: self._nodes, constant=True)\n    chunksCreated = Signal(BaseObject)\n    restartRequested = Signal()\n"
  },
  {
    "path": "meshroom/core/test.py",
    "content": "#!/usr/bin/env python\n\nimport json\n\nfrom meshroom.core import pipelineTemplates, Version\nfrom meshroom.core.node import CompatibilityIssue, CompatibilityNode\nfrom meshroom.core.graphIO import GraphIO\n\nimport meshroom\n\ndef checkTemplateVersions(path: str, nodesAlreadyLoaded: bool = False) -> bool:\n    \"\"\" Check whether there is a compatibility issue with the nodes saved in the template provided with \"path\". \"\"\"\n    if not nodesAlreadyLoaded:\n        meshroom.core.initNodes()\n\n    with open(path) as jsonFile:\n        fileData = json.load(jsonFile)\n\n    try:\n        graphData = fileData.get(GraphIO.Keys.Graph, fileData)\n        if not isinstance(graphData, dict):\n            print(f\"File '{path}' does not contain a valid graph.\")\n            return False\n\n        header = fileData.get(GraphIO.Keys.Header, {})\n        if not header.get(\"template\", False):\n            print(f\"File '{path}' is not a valid template.\")\n            return False\n        nodesVersions = header.get(GraphIO.Keys.NodesVersions, {})\n\n        for _, nodeData in graphData.items():\n            nodeType = nodeData[\"nodeType\"]\n            if not meshroom.core.pluginManager.isRegistered(nodeType):\n                print(f\"'{nodeType}' in '{path}' is an unknown type.\")\n                return False\n\n            nodeDesc = meshroom.core.pluginManager.getRegisteredNodePlugin(nodeType).nodeDescriptor\n            currentNodeVersion = meshroom.core.nodeVersion(nodeDesc)\n\n            inputs = nodeData.get(\"inputs\", {})\n            internalInputs = nodeData.get(\"internalInputs\", {})\n            version = nodesVersions.get(nodeType, None)\n\n            compatibilityIssue = None\n\n            if version and currentNodeVersion and Version(version).major != Version(currentNodeVersion).major:\n                compatibilityIssue = CompatibilityIssue.VersionConflict\n            else:\n                for attrName, value in inputs.items():\n                    if not CompatibilityNode.attributeDescFromName(nodeDesc.inputs, attrName, value):\n                        compatibilityIssue = CompatibilityIssue.DescriptionConflict\n                        break\n                for attrName, value in internalInputs.items():\n                    if not CompatibilityNode.attributeDescFromName(nodeDesc.internalInputs, attrName, value):\n                        compatibilityIssue = CompatibilityIssue.DescriptionConflict\n                        break\n\n            if compatibilityIssue is not None:\n                print(f\"{compatibilityIssue} in '{path}' for node '{nodeType}'.\")\n                return False\n\n        return True\n\n    finally:\n        if not nodesAlreadyLoaded:\n            nodePlugins = meshroom.core.pluginManager.getRegisteredNodePlugins()\n            for node in nodePlugins:\n                meshroom.core.pluginManager.unregisterNode(node)\n\n\ndef checkAllTemplatesVersions() -> bool:\n    meshroom.core.initNodes()\n    meshroom.core.initPipelines()\n\n    validVersions = []\n    for _, path in pipelineTemplates.items():\n        validVersions.append(checkTemplateVersions(path, nodesAlreadyLoaded=True))\n\n    return all(validVersions)\n"
  },
  {
    "path": "meshroom/core/utils.py",
    "content": "COLORSPACES = [\"AUTO\", \"sRGB\", \"rec709\", \"Linear\", \"ACES2065-1\", \"ACEScg\", \"Linear ARRI Wide Gamut 3\",\n               \"ARRI LogC3 (EI800)\", \"Linear ARRI Wide Gamut 4\", \"ARRI LogC4\", \"Linear BMD WideGamut Gen5\",\n               \"BMDFilm WideGamut Gen5\", \"CanonLog2 CinemaGamut D55\", \"CanonLog3 CinemaGamut D55\",\n               \"Linear CinemaGamut D55\", \"Linear V-Gamut\", \"V-Log V-Gamut\", \"Linear REDWideGamutRGB\",\n               \"Log3G10 REDWideGamutRGB\", \"Linear Venice S-Gamut3.Cine\", \"S-Log3 Venice S-Gamut3.Cine\", \"no_conversion\"]\n\nDESCRIBER_TYPES = [\"sift\", \"sift_float\", \"sift_upright\", \"dspsift\", \"akaze\", \"akaze_liop\", \"akaze_mldb\", \"cctag3\",\n                   \"cctag4\", \"sift_ocv\", \"akaze_ocv\", \"tag16h5\", \"survey\", \"unknown\"]\n\nEXR_STORAGE_DATA_TYPE = [\"float\", \"half\", \"halfFinite\", \"auto\"]\n\nRAW_COLOR_INTERPRETATION = [\"None\", \"LibRawNoWhiteBalancing\", \"LibRawWhiteBalancing\", \"DCPLinearProcessing\",\n                            \"DCPMetadata\", \"Auto\"]\n\nVERBOSE_LEVEL = [\"fatal\", \"error\", \"warning\", \"info\", \"debug\", \"trace\"]\n"
  },
  {
    "path": "meshroom/env.py",
    "content": "\"\"\"\nMeshroom environment variable management.\n\"\"\"\n\n__all__ = [\n    \"EnvVar\",\n    \"EnvVarHelpAction\",\n]\n\nimport argparse\nimport os\nfrom dataclasses import dataclass\nfrom enum import Enum\nimport sys\nimport tempfile\nfrom typing import Any, Type\n\nmeshroomFolder = os.path.dirname(__file__)\n\n@dataclass\nclass VarDefinition:\n    \"\"\"Environment variable definition.\"\"\"\n\n    # The type to cast the value to.\n    valueType: Type\n    # Default value if the variable is not set in the environment.\n    default: str\n    # Description of the purpose of the variable.\n    description: str = \"\"\n\n    def __str__(self) -> str:\n        return f\"{self.description} ({self.valueType.__name__}, default: '{self.default}')\"\n\n\nclass EnvVar(Enum):\n    \"\"\"Meshroom environment variables catalog.\"\"\"\n\n    # UI - Debug\n    MESHROOM_QML_DEBUG = VarDefinition(bool, \"False\", \"Enable QML debugging\")\n    MESHROOM_QML_DEBUG_PARAMS = VarDefinition(\n        str, \"port:3768\", \"QML debugging params as expected by -qmljsdebugger\"\n    )\n\n    # Core\n    MESHROOM_PLUGINS_PATH = VarDefinition(str, \"\", \"Paths to plugins folders containing nodes, submitters and pipeline templates.\")\n    MESHROOM_NODES_PATH = VarDefinition(str, \"\", \"Paths to set of nodes folders.\")\n    MESHROOM_SUBMITTERS_PATH = VarDefinition(str, \"\", \"Paths to set of submitters folders.\")\n    MESHROOM_PIPELINE_TEMPLATES_PATH = VarDefinition(str, \"\", \"Paths to et of pipeline templates folders.\")\n    MESHROOM_REZ_PLUGINS = VarDefinition(str, \"\", \"List of Rez plugins, defined by the package name associated with the plugin's root path. \"\n                                                  \"For example, 'packageA=/path/to/packageA/version/root'.\")\n    MESHROOM_TEMP_PATH = VarDefinition(str, tempfile.gettempdir(), \"Path to the temporary folder.\")\n\n\n    @staticmethod\n    def get(envVar: \"EnvVar\") -> Any:\n        \"\"\"Get the value of `envVar`, cast to the variable type.\"\"\"\n        value = os.environ.get(envVar.name, envVar.value.default)\n        return EnvVar._cast(value, envVar.value.valueType)\n\n    @staticmethod\n    def getList(envVar: \"EnvVar\") -> list[Any]:\n        \"\"\"Get the value of `envVar` as a list of non-empty strings.\"\"\"\n        paths = EnvVar.get(envVar).split(os.pathsep)\n        # filter empty values\n        return [p for p in paths if p]\n\n    @staticmethod\n    def _cast(value: str, valueType: Type) -> Any:\n        if valueType is str:\n            return value\n        elif valueType is bool:\n            return value.lower() in {\"true\", \"1\", \"on\", \"yes\", \"y\"}\n        return valueType(value)\n\n    @classmethod\n    def help(cls) -> str:\n        \"\"\"Return a formatted string with the details of each environment variables.\"\"\"\n        return \"\\n\".join([f\"{var.name}: {var.value}\" for var in cls])\n\n\nclass EnvVarHelpAction(argparse.Action):\n    \"\"\"Argparse action for printing Meshroom environment variables help and exit.\"\"\"\n\n    DEFAULT_HELP = \"Print Meshroom environment variables help and exit.\"\n\n    def __call__(self, parser, namespace, value, option_string=None):\n        print(\"Meshroom environment variables:\")\n        print(EnvVar.help())\n        sys.exit(0)\n"
  },
  {
    "path": "meshroom/multiview.py",
    "content": "import os\n\n# Supported image extensions\nimageExtensions = [\n    # bmp:\n    '.bmp',\n    # cineon:\n    '.cin',\n    # dds\n    '.dds',\n    # dpx:\n    '.dpx',\n    # gif:\n    '.gif',\n    # hdr:\n    '.hdr', '.rgbe',\n    # heif\n    '.heic', '.heif', '.avif',\n    # ico:\n    '.ico',\n    # iff:\n    '.iff', '.z',\n    # jpeg:\n    '.jpg', '.jpe', '.jpeg', '.jif', '.jfif', '.jfi',\n    # jpeg2000:\n    '.jp2', '.j2k', '.j2c',\n    # openexr:\n    '.exr', '.sxr', '.mxr',\n    # png:\n    '.png',\n    # pnm:\n    '.ppm', '.pgm', '.pbm', '.pnm', '.pfm',\n    # psd:\n    '.psd', '.pdd', '.psb',\n    # ptex:\n    '.ptex', '.ptx',\n    # raw:\n    '.bay', '.bmq', '.cr2', '.cr3', '.crw', '.cs1', '.dc2', '.dcr', '.dng', '.erf', '.fff', '.k25', '.kdc', '.mdc',\n    '.mos', '.mrw', '.nef', '.orf', '.pef', '.pxn', '.raf', '.raw', '.rdc', '.sr2', '.srf', '.x3f', '.arw', '.3fr',\n    '.cine', '.ia', '.kc2', '.mef', '.nrw', '.qtk', '.rw2', '.sti', '.rwl', '.srw', '.drf', '.dsc', '.cap', '.iiq',\n    '.rwz',\n    # rla:\n    '.rla',\n    # sgi:\n    '.sgi', '.rgb', '.rgba', '.bw', '.int', '.inta',\n    # socket:\n    '.socket',\n    # softimage:\n    '.pic',\n    # tiff:\n    '.tiff', '.tif', '.tx', '.env', '.sm', '.vsm',\n    # targa:\n    '.tga', '.tpic',\n    # webp:\n    'webp',\n    # zfile:\n    '.zfile',\n    # osl:\n    '.osl', '.oso', '.oslgroup', '.oslbody',\n    ]\nvideoExtensions = [\n    '.avi', '.mov', '.qt',\n    '.mkv', '.webm',\n    '.mp4', '.mpg', '.mpeg', '.m2v', '.m4v',\n    '.wmv',\n    '.ogv', '.ogg',\n    '.mxf',\n    ]\npanoramaInfoExtensions = ['.xml']\nmeshroomSceneExtensions = ['.mg']\n\n\ndef hasExtension(filepath, extensions):\n    \"\"\" Return whether filepath is one of the following extensions. \"\"\"\n    if os.path.isdir(filepath):\n        return False\n    return os.path.splitext(filepath)[1].lower() in extensions\n\n\nclass FilesByType:\n    def __init__(self):\n        self.images = []\n        self.videos = []\n        self.panoramaInfo = []\n        self.meshroomScenes = []\n        self.other = []\n\n    def __bool__(self):\n        return self.images or self.videos or self.panoramaInfo or self.meshroomScenes\n\n    def extend(self, other):\n        self.images.extend(other.images)\n        self.videos.extend(other.videos)\n        self.panoramaInfo.extend(other.panoramaInfo)\n        self.meshroomScenes.extend(other.meshroomScenes)\n        self.other.extend(other.other)\n\n    def addFile(self, file):\n        if hasExtension(file, imageExtensions):\n            self.images.append(file)\n        elif hasExtension(file, videoExtensions):\n            self.videos.append(file)\n        elif hasExtension(file, panoramaInfoExtensions):\n            self.panoramaInfo.append(file)\n        elif hasExtension(file, meshroomSceneExtensions):\n            self.meshroomScenes.append(file)\n        else:\n            self.other.append(file)\n\n    def addFiles(self, files):\n        for file in files:\n            self.addFile(file)\n\n\ndef findFilesByTypeInFolder(folder, recursive=False):\n    \"\"\"\n    Return all files that are images in 'folder' based on their extensions.\n\n    Args:\n        folder (str): folder to look into or list of folder/files\n\n    Returns:\n        list: the list of image files with a supported extension.\n    \"\"\"\n    inputFolders = []\n    if isinstance(folder, (list, tuple)):\n        inputFolders = folder\n    else:\n        inputFolders.append(folder)\n\n    output = FilesByType()\n    for currentFolder in inputFolders:\n        currentFolder = os.path.abspath(currentFolder)\n        if os.path.isfile(currentFolder):\n            output.addFile(currentFolder)\n            continue\n        elif os.path.isdir(currentFolder):\n            if recursive:\n                # Get through all of the depth levels\n                for root, directories, files in os.walk(currentFolder):\n                    for filename in files:\n                        output.addFile(os.path.join(root, filename))\n            else:\n                # Only get the first level of depth, so top-level folders'\n                # files will be added, if they exist.\n                # This may prevent from importing nothing at all when files\n                # are nested a level down\n                try:\n                    root, directories, files = next(os.walk(currentFolder))\n                    output.addFiles([os.path.join(root, file) for file in files])\n                    for directory in directories:\n                        for file in os.listdir(os.path.join(root, directory)):\n                            filepath = os.path.join(root, directory, file)\n                            if os.path.isfile(filepath):\n                                output.addFile(filepath)\n                except (StopIteration, OSError):\n                    # Directory empty or inaccessible, skip processing\n                    pass\n\n        else:\n            # If not a directory or a file, it may be an expression\n            import glob\n            paths = glob.glob(currentFolder)\n            filesByType = findFilesByTypeInFolder(paths, recursive=recursive)\n            output.extend(filesByType)\n\n    return output\n"
  },
  {
    "path": "meshroom/nodes/__init__.py",
    "content": ""
  },
  {
    "path": "meshroom/nodes/general/Backdrop.py",
    "content": "__version__ = \"1.0\"\n\nfrom meshroom.core import desc\n\nclass Backdrop(desc.BackdropNode):\n    \"\"\" Backdrop node. Any node can be placed inside a backdrop to group them visually. \"\"\"\n\n    category = \"Utils\"\n"
  },
  {
    "path": "meshroom/nodes/general/CopyFiles.py",
    "content": "__version__ = \"1.3\"\n\nfrom meshroom.core import desc\nfrom meshroom.core.utils import VERBOSE_LEVEL\n\nimport shutil\nimport glob\nimport os\n\n\nclass CopyFiles(desc.Node):\n    size = desc.DynamicNodeSize(\"inputFiles\")\n\n    category = \"Export\"\n    documentation = \"\"\"\nThis node allows to copy files into a specific folder.\n\"\"\"\n\n    inputs = [\n        desc.ListAttribute(\n            elementDesc=desc.File(\n                name=\"input\",\n                label=\"Input\",\n                description=\"File or folder to copy.\",\n                value=\"\",\n            ),\n            name=\"inputFiles\",\n            label=\"Input Files\",\n            description=\"Input files or folders' content to copy.\",\n            exposed=True,\n        ),\n        desc.File(\n            name=\"output\",\n            label=\"Output Folder\",\n            description=\"Folder to copy to.\",\n            value=\"\",\n        ),\n        desc.ChoiceParam(\n            name=\"verboseLevel\",\n            label=\"Verbose Level\",\n            description=\"Verbosity level (fatal, error, warning, info, debug, trace).\",\n            values=VERBOSE_LEVEL,\n            value=\"info\",\n        ),\n    ]\n\n    def resolvedPaths(self, inputFiles, outDir):\n        paths = {}\n        for inputFile in inputFiles:\n            for f in glob.glob(inputFile.value):\n                if os.path.isdir(f):\n                    # Do not concatenate the input folder's name with the output's\n                    paths[f] = outDir\n                else:\n                    paths[f] = os.path.join(outDir, os.path.basename(f))\n        return paths\n\n    def processChunk(self, chunk):\n        try:\n            chunk.logManager.start(chunk.node.verboseLevel.value)\n\n            if not chunk.node.inputFiles:\n                chunk.logger.warning(\"No file to copy.\")\n                return\n            if not chunk.node.output.value:\n                return\n\n            outFiles = self.resolvedPaths(chunk.node.inputFiles.value, chunk.node.output.value)\n\n            if not outFiles:\n                error = \"CopyFiles: input files listed, but nothing to copy.\"\n                chunk.logger.error(error)\n                chunk.logger.info(f\"Listed input files: {[i.value for i in chunk.node.inputFiles.value]}.\")\n                raise RuntimeError(error)\n\n            if not os.path.exists(chunk.node.output.value):\n                os.makedirs(chunk.node.output.value)\n\n            for iFile, oFile in outFiles.items():\n                # If the input is a directory, copy the directory's content\n                if os.path.isdir(iFile):\n                    chunk.logger.info(f\"CopyFiles directory {iFile} into {oFile}.\")\n                    shutil.copytree(iFile, oFile)\n                else:\n                    chunk.logger.info(f\"CopyFiles file {iFile} into {oFile}.\")\n                    shutil.copyfile(iFile, oFile)\n            chunk.logger.info(\"CopyFiles end.\")\n        finally:\n            chunk.logManager.end()\n"
  },
  {
    "path": "meshroom/nodes/general/InputFile.py",
    "content": "__version__ = \"1.0\"\n\nimport logging\nimport os\n\nfrom meshroom.core import desc\n\nclass InputFile(desc.InputNode, desc.InitNode):\n    \"\"\"\nThis node is an input node that receives a File.\n\"\"\"\n    category = \"Other\"\n\n    inputs = [\n        desc.File(\n            name=\"inputFile\",\n            label=\"Input File\",\n            description=\"A file or folder to use as the input.\",\n            value=\"\",\n        )\n    ]\n\n    def initialize(self, node, inputs, recursiveInputs):\n        self.resetAttributes(node, [\"inputFile\"])\n\n        if len(inputs) >= 1:\n            if os.path.isfile(inputs[0]) or os.path.isdir(inputs[0]):\n                self.setAttributes(node, {\"inputFile\": inputs[0]})\n\n                if len(inputs) > 1:\n                    logging.warning(f\"Several inputs were provided ({inputs}).\")\n                    logging.warning(f\"Only the first one ({inputs[0]}) will be used.\")\n            else:\n                raise RuntimeError(f\"{inputs[0]} is not a valid file or directory.\")\n\n        elif len(recursiveInputs) >= 1:\n            if os.path.isfile(recursiveInputs[0]) or os.path.isdir(recursiveInputs[0]):\n                self.setAttributes(node, {\"inputFile\": recursiveInputs[0]})\n\n                if len(recursiveInputs) > 1:\n                    logging.warning(f\"Several recursive inputs were provided ({recursiveInputs}).\")\n                    logging.warning(f\"Only the first valid one ({recursiveInputs[0]}) will be used.\")\n\n            else:\n                raise RuntimeError(f\"{recursiveInputs[0]} is not a valid file or directory.\")\n\n        else:\n            raise RuntimeError(\"No file or directory has been set for 'inputFile'.\")\n"
  },
  {
    "path": "meshroom/nodes/general/__init__.py",
    "content": ""
  },
  {
    "path": "meshroom/submitters/__init__.py",
    "content": ""
  },
  {
    "path": "meshroom/submitters/localFarmSubmitter.py",
    "content": "#!/usr/bin/env python\n\nimport os\nimport re\nimport shutil\nimport logging\nfrom pathlib import Path\nfrom typing import List, Dict\nfrom meshroom.core.submitter import BaseSubmitter, SubmitterOptions, BaseSubmittedJob, SubmitterOptionsEnum\nfrom meshroom.core.node import Status\nfrom collections import namedtuple, defaultdict\n\nfrom localfarm.localFarm import Task, Job, LocalFarmEngine\n\n\nlogger = logging.getLogger(\"LocalFarmSubmitter\")\nlogger.setLevel(logging.INFO)\n\n\nDEFAULT_FARM_PATH = os.getenv(\"MR_LOCAL_FARM_PATH\", os.path.join(os.path.expanduser(\"~\"), \".local_farm\"))\nREZ_DELIMITER_PATTERN = re.compile(r\"(-|==|>=|>|<=|<)\")\nMESHROOM_ROOT = Path(__file__).resolve().parent.parent.parent\n\n\nChunk = namedtuple(\"chunk\", [\"iteration\", \"start\", \"end\"])\nCreatedTask = namedtuple(\"task\", [\"task\", \"chunkParams\"])\n\n\ndef wrapMeshroomBin(_bin):\n    if shutil.which(_bin):\n        # The alias exists so use it directly\n        return _bin\n    binFolder = str(MESHROOM_ROOT / \"bin\")\n    return os.path.join(binFolder, _bin)\n\n\ndef getResolvedVersionsDict():\n    \"\"\" Get a dict {packageName: version} corresponding to the current context. \"\"\"\n    resolvedPackages = os.environ.get('REZ_RESOLVE', '').split()\n    resolvedVersions = {}\n    for r in resolvedPackages:\n        if r.startswith('~'):  # remove implicit packages\n            continue\n        v = r.split('-')\n        if len(v) == 2:\n            resolvedVersions[v[0]] = v[1]\n        elif len(v) > 2:  # Handle case with multiple hyphen-minus\n            resolvedVersions[v[0]] = \"-\".join(v[1:])\n    return resolvedVersions\n\n\ndef getRequestPackages(packagesDelimiter=\"==\"):\n    \"\"\"\n    Get list of packages required for the job.\n    Depends on env var and current rez context.\n\n    By default we use the \"==\" delimiter to make sure we have the same version\n    in the job that the one we have in the env where Meshroom is launched.\n    \"\"\"\n    reqPackages = set()\n    if 'REZ_REQUEST' in os.environ:\n        # Get the names of the packages that have been requested\n        requestedPackages = os.environ.get('REZ_USED_REQUEST', '').split()\n        usedPackages = set()  # Use set to remove duplicates\n        for p in requestedPackages:\n            if p.startswith('~') or p.startswith(\"!\"):\n                continue\n            v = REZ_DELIMITER_PATTERN.split(p)\n            usedPackages.add(v[0])\n        # Add requested packages to the reqPackages set\n        resolvedVersions = getResolvedVersionsDict()\n        for p in usedPackages:\n            reqPackages.add(packagesDelimiter.join([p, resolvedVersions[p]]))\n        logging.debug(f\"LocalFarmSubmitter: REZ Packages: {str(reqPackages)}\")\n    elif 'REZ_MESHROOM_VERSION' in os.environ:\n        reqPackages.add(f\"meshroom{packagesDelimiter}{os.environ.get('REZ_MESHROOM_VERSION', '')}\")\n    return list(reqPackages)\n\n\ndef rezWrapCommand(cmd, useCurrentContext=False, useRequestedContext=True, otherRezPkg: list[str] = None):\n    \"\"\" Wrap command to be runned using rez.\n    :param cmd: command to run\n    :type cmd: bool\n    :param useCurrentContext: use current rez context to retrieve a list of rez packages\n    :type useCurrentContext: bool\n    :param useRequestedContext: use rez packages that have been requested (not the full context)  # TODO : remove it\n    :type useRequestedContext: bool\n    :param otherRezPkg: Additionnal rez packages\n    :type otherRezPkg: list[str]\n    \"\"\"\n    packages = set()\n    if useCurrentContext:\n        # In this case we want to use the full context\n        packages.update([p for p in os.environ.get('REZ_RESOLVE', '').split(\" \") if p])\n    elif useRequestedContext:\n        # In this case we want to use only packages in the rez request\n        packages.update(getRequestPackages())\n    # Add additional packages\n    if otherRezPkg:\n        packages.update(otherRezPkg)\n    packagesStr = \" \".join([p for p in packages if p])\n    if packagesStr:\n        rezBin = \"rez\"\n        if \"REZ_BIN\" in os.environ and os.environ[\"REZ_BIN\"]:\n            rezBin = os.environ[\"REZ_BIN\"]\n        elif \"REZ_PACKAGES_ROOT\" in os.environ and os.environ[\"REZ_PACKAGES_ROOT\"]:\n            rezBin = os.path.join(os.environ[\"REZ_PACKAGES_ROOT\"], \"bin/rez\")\n        elif shutil.which(\"rez\"):\n            rezBin = shutil.which(\"rez\")\n        return f\"{rezBin} env {packagesStr} -- {cmd}\"\n    return cmd\n\n\nclass LocalFarmJob(BaseSubmittedJob):\n    \"\"\" Interface to manipulate the job via Meshroom. \"\"\"\n\n    def __init__(self, jid, submitter, farmPath=None):\n        super().__init__(jid, submitter)\n        self.jid = jid\n        self.submitter: LocalFarmSubmitter = submitter\n        self.__localJob = None\n        self.__localJobTasks = None\n        self.farmPath = farmPath or DEFAULT_FARM_PATH\n        self._engine = LocalFarmEngine(self.farmPath)\n\n    def __getJobInfo(self):\n        \"\"\" Find job. \"\"\"\n        self.__localJob = self._engine.get_job_info(self.jid)\n        self.__localJobTasks = {t.get(\"tid\"): t for t in self.__localJob[\"tasks\"]}\n\n    @property\n    def localfarmJob(self):\n        if not self.__localJob:\n            self.__getJobInfo()\n        return self.__localJob\n\n    @property\n    def localfarmTasks(self):\n        if not self.__localJobTasks:\n            self.__getJobInfo()\n        return self.__localJobTasks\n\n    def __getChunkTasks(self, nodeUid, iteration):\n        tasks = []\n        for _, task in self.localfarmTasks.items():\n            taskNodeUid = task[\"metadata\"].get(\"nodeUid\", None)\n            taskIt = task[\"metadata\"].get(\"iteration\", -1)\n            if taskNodeUid == nodeUid and taskIt == iteration:\n                tasks.append(task)\n        return tasks\n\n    # Task actions\n\n    def stopChunkTask(self, node, iteration):\n        \"\"\" This will kill one task. \"\"\"\n        tasks = self.__getChunkTasks(node._uid, iteration)\n        for task in tasks:\n            self._engine.stop_task(self.jid, task[\"tid\"])\n\n    def skipChunkTask(self, node, iteration):\n        \"\"\" This will skip one task. \"\"\"\n        tasks = self.__getChunkTasks(node._uid, iteration)\n        for task in tasks:\n            self._engine.skip_task(self.jid, task[\"tid\"])\n\n    def restartChunkTask(self, node, iteration):\n        \"\"\" This will restart one task. \"\"\"\n        tasks = self.__getChunkTasks(node._uid, iteration)\n        for task in tasks:\n            self._engine.restart_task(self.jid, task[\"tid\"])\n\n    # Job actions\n\n    def getJobErrors(self):\n        \"\"\" Check for error in the job. \"\"\"\n        return self._engine.get_job_errors(self.jid)\n\n    def pauseJob(self):\n        \"\"\" This will pause the job: new tasks will not be processed. \"\"\"\n        self._engine.pause_job(self.jid)\n\n    def resumeJob(self):\n        \"\"\" This will unpause the job. \"\"\"\n        self._engine.unpause_job(self.jid)\n\n    def interruptJob(self):\n        \"\"\" This will interrupt the job (and kill running tasks). \"\"\"\n        self._engine.interrupt_job(self.jid)\n\n    def restartJob(self):\n        \"\"\" Restart the whole job. \"\"\"\n        self._engine.restart_job(self.jid)\n\n    def restartErrorTasks(self):\n        \"\"\" Restart all error tasks on the job. \"\"\"\n        self._engine.restart_error_tasks(self.jid)\n\n\nclass LocalFarmSubmitter(BaseSubmitter):\n    \"\"\" Meshroom submitter to localfarm. \"\"\"\n\n    _name = \"LocalFarm\"\n    _options = SubmitterOptions(SubmitterOptionsEnum.ALL)\n\n    dryRun = False\n    environment = {}\n\n    def __init__(self, parent=None):\n        super().__init__(parent=parent)\n        self.farmPath = DEFAULT_FARM_PATH\n        self.reqPackages = getRequestPackages()\n        self.jobEnv = {}\n\n    def setFarmPath(self, path: str):\n        self.farmPath = path\n\n    def setJobEnv(self, env: dict):\n        self.jobEnv = env\n\n    def retrieveJob(self, jid) -> LocalFarmJob:\n        job = LocalFarmJob(jid, self, farmPath=self.farmPath)\n        return job\n\n    @staticmethod\n    def getChunks(chunkParams) -> list[Chunk]:\n        \"\"\" Get list of chunks. \"\"\"\n        it = None\n        ignoreIterations = chunkParams.get(\"ignoreIterations\", [])\n        if chunkParams:\n            start, end = chunkParams.get(\"start\", -1), chunkParams.get(\"end\", -2)\n            size = chunkParams.get(\"packetSize\", 1)\n            frameRange = list(range(start, end+1, 1))\n            if frameRange:\n                slices = [frameRange[i:i + size] for i in range(0, len(frameRange), size)]\n                it = [Chunk(i, item[0], item[-1]) for i, item in enumerate(slices) if i not in ignoreIterations]\n        return it\n\n    @staticmethod\n    def getExpandWrappedCmd(cmdArgs, rezPackages):\n        # Wrap with create_chunks\n        cmdBin = wrapMeshroomBin(\"meshroom_createChunks\")\n        cmd = f\"{cmdBin} --submitter LocalFarm {cmdArgs}\"\n        # Wrap with rez\n        cmd = rezWrapCommand(cmd, otherRezPkg=rezPackages)\n        return cmd\n\n    def __createChunkTasks(self, job: Job, parentTask: Task, children: List[Task], chunkParams: dict) -> Task:\n        cmdArgs = chunkParams.get(\"chunkCmdArgs\")\n        chunks = self.getChunks(chunkParams)\n        for c in chunks:\n            name = f\"{parentTask.name}_{c.start}_{c.end}\"\n            meta = parentTask.metadata.copy()\n            meta[\"iteration\"] = c.iteration\n            cmdBin = wrapMeshroomBin(\"meshroom_compute\")\n            cmd = f\"{cmdBin} {cmdArgs} --iteration {c.iteration}\"\n            cmd = rezWrapCommand(cmd, otherRezPkg=self.reqPackages)\n            chunkTask = Task(name=name, command=cmd, metadata=meta, env=self.jobEnv)\n            job.addTask(chunkTask)\n            for child in children:\n                job.addTaskDependency(child, chunkTask)\n            job.addTaskDependency(chunkTask, parentTask)\n\n    def createTask(self, meshroomFile: str, node) -> CreatedTask:\n        cmdArgs = f\"--node {node.name} \\\"{meshroomFile}\\\" --extern\"\n        metadata = {\"nodeUid\": node._uid}\n\n        if not node._chunksCreated:\n            cmd = self.getExpandWrappedCmd(cmdArgs, self.reqPackages)\n            task = Task(name=node.name, command=cmd, metadata=metadata, env=self.jobEnv)\n            task = CreatedTask(task, None)\n\n        elif node.isParallelized:\n            _, _, nbBlocks = node.nodeDesc.parallelization.getSizes(node)\n            iterationsToIgnore = []\n            for c in node._chunks:\n                if c._status.status == Status.SUCCESS:\n                    iterationsToIgnore.append(c.range.iteration)\n            chunkParams = {\n                \"start\": 0, \"end\": nbBlocks - 1, \"step\": 1,\n                \"ignoreIterations\": iterationsToIgnore,\n                \"chunkCmdArgs\": cmdArgs\n            }\n            task = Task(name=node.name, command=\"\", metadata=metadata, env=self.jobEnv)\n            task = CreatedTask(task, chunkParams)\n\n        else:\n            cmdBin = wrapMeshroomBin(\"meshroom_compute\")\n            cmd = f\"{cmdBin} {cmdArgs} --iteration 0\"\n            cmd = rezWrapCommand(cmd, otherRezPkg=self.reqPackages)\n            task = Task(name=node.name, command=cmd, metadata=metadata, env=self.jobEnv)\n            task = CreatedTask(task, None)\n\n        print(\"Created task: \", task)\n\n        return task\n\n    def buildDependencies(self, job: Job, nodeUidToTask: Dict[str, CreatedTask], edges):\n        \"\"\" Gather and create dependencies.\n        First we get all parents and all children for each task.\n        Then for each task:\n        - we add the dependency to their parent and children\n        - if the task is a chunked task (which means multi iteration tasks) the we create the\n          chunk tasks and add dependencies from chunk tasks to children tasks\n\n        # TODO: there is a lot of confusion between nodes and tasks here\n        \"\"\"\n        # Gather dependencies\n        tasksParentsUids = defaultdict(set)\n        tasksChildrenUids = defaultdict(set)\n        for u, v in edges:\n            # tasksParentsUids[v._uid].add(u._uid)\n            # tasksChildrenUids[u._uid].add(v._uid)\n            tasksParentsUids[u._uid].add(v._uid)\n            tasksChildrenUids[v._uid].add(u._uid)\n        # Create dependencies\n        for taskUid, createdTask in nodeUidToTask.items():\n            parentsTasks = [nodeUidToTask[tuid].task for tuid in tasksParentsUids.get(taskUid, set())]\n            childrenTasks = [nodeUidToTask[tuid].task for tuid in tasksChildrenUids.get(taskUid, set())]\n            # Create regular dependencies\n            for parentTask in parentsTasks:\n                job.addTaskDependency(createdTask.task, parentTask)\n            for childTask in childrenTasks:\n                job.addTaskDependency(childTask, createdTask.task)\n            # Create chunk tasks if necessary\n            if createdTask.chunkParams:\n                self.__createChunkTasks(job, createdTask.task, childrenTasks, createdTask.chunkParams)\n\n    def createJob(self, nodes, edges, filepath, submitLabel=\"{projectName}\") -> LocalFarmJob:\n        projectName = os.path.splitext(os.path.basename(filepath))[0]\n        name = submitLabel.format(projectName=projectName)\n        # Create job\n        job = Job(name)\n        # Create tasks\n        nodeUidToTask: Dict[str, CreatedTask] = {}\n        for node in nodes:\n            if node._uid in nodeUidToTask:\n                continue  # HACK: Should not be necessary\n            createdTask: CreatedTask = self.createTask(filepath, node)\n            job.addTask(createdTask.task)\n            nodeUidToTask[node._uid] = createdTask\n        # Build dependencies\n        self.buildDependencies(job, nodeUidToTask, edges)\n        # Submit job\n        engine = LocalFarmEngine(self.farmPath)\n        res = job.submit(engine)\n        print(f\"Submitted job : {res}\")\n        if self.dryRun:\n            return True\n        if len(res) == 0:\n            return False\n        submittedJob = LocalFarmJob(res.get(\"jid\"), LocalFarmSubmitter, farmPath=self.farmPath)\n        return submittedJob\n\n    def createChunkTask(self, node, graphFile, **kwargs):\n        \"\"\"\n        Dynamically create chunk tasks for the given node (executed by meshroom_createChunks).\n        \"\"\"\n        # Retrieve current job/task info\n        currentJid, currentTid = int(os.getenv(\"LOCALFARM_CURRENT_JID\")), int(os.getenv(\"LOCALFARM_CURRENT_TID\"))\n        # Make sure we inherit current MESHROOM_PLUGINS_PATH for submission\n        # TODO: later we can immplement a proper env inheriting system like what we have in tractor\n        taskEnv = {\n            \"MESHROOM_PLUGINS_PATH\": os.environ.get(\"MESHROOM_PLUGINS_PATH\", \"\")\n        }\n        if self.jobEnv:\n            taskEnv.update(self.jobEnv)\n        # Get engine\n        engine = LocalFarmEngine(self.farmPath)\n        # Get chunk info\n        cmdArgs = f\"--node {node.name} \\\"{graphFile}\\\" --extern\"\n        _, _, nbBlocks = node.nodeDesc.parallelization.getSizes(node)\n        if nbBlocks <= 0:\n            return\n        chunkRangeParams = {'start': 0, 'end': nbBlocks - 1, 'step': 1}\n        # Create subtasks\n        for chunk in self.getChunks(chunkRangeParams):\n            name = f\"{node.name}_{chunk.start}_{chunk.end}\"\n            metadata = {\"nodeUid\": node._uid, \"iteration\": chunk.iteration}\n            cmdBin = wrapMeshroomBin(\"meshroom_compute\")\n            cmd = f\"{cmdBin} {cmdArgs} --iteration {chunk.iteration}\"\n            cmd = rezWrapCommand(cmd, otherRezPkg=self.reqPackages)\n            print(\"Additional chunk task command: \", cmd)\n            task = Task(name=name, command=cmd, metadata=metadata, env=taskEnv)\n            engine.create_additional_task(currentJid, currentTid, task)\n"
  },
  {
    "path": "meshroom/ui/__init__.py",
    "content": ""
  },
  {
    "path": "meshroom/ui/__main__.py",
    "content": "import signal\nimport sys\nimport meshroom\nfrom meshroom.common import Backend\n\nmeshroom.setupEnvironment(backend=Backend.PYSIDE)\n\nsignal.signal(signal.SIGINT, signal.SIG_DFL)\nimport meshroom.ui\nimport meshroom.ui.app\n\nmeshroom.ui.uiInstance = meshroom.ui.app.MeshroomApp(sys.argv)\nmeshroom.ui.uiInstance.aboutToQuit.connect(meshroom.ui.uiInstance.terminateManual)\nmeshroom.ui.uiInstance.exec()\n"
  },
  {
    "path": "meshroom/ui/app.py",
    "content": "import logging\nimport os\nimport re\nimport argparse\nimport json\nfrom enum import Enum\n\nfrom PySide6 import __version__ as PySideVersion\nfrom PySide6 import QtCore\nfrom PySide6.QtCore import QUrl, QJsonValue, qInstallMessageHandler, QtMsgType, QSettings\nfrom PySide6.QtGui import QIcon\nfrom PySide6.QtQml import QQmlDebuggingEnabler\nfrom PySide6.QtQuickControls2 import QQuickStyle\nfrom PySide6.QtWidgets import QApplication\n\nimport meshroom\nfrom meshroom.core import pluginManager\nfrom meshroom.core.submitter import BaseSubmitter\nfrom meshroom.core.taskManager import TaskManager\nfrom meshroom.common import Property, Variant, Signal, Slot\n\nfrom meshroom.env import EnvVar, EnvVarHelpAction\n\nfrom meshroom.ui import components\nfrom meshroom.ui.components.clipboard import ClipboardHelper\nfrom meshroom.ui.components.filepath import FilepathHelper\nfrom meshroom.ui.components.scene3D import Scene3DHelper, Transformations3DHelper\nfrom meshroom.ui.components.scriptEditor import ScriptEditorManager\nfrom meshroom.ui.components.thumbnail import ThumbnailCache\nfrom meshroom.ui.components.messaging import MessageController\nfrom meshroom.ui.components.shapes import ShapeFilesHelper, ShapeViewerHelper\nfrom meshroom.ui.palette import PaletteManager\nfrom meshroom.ui.scene import Scene\nfrom meshroom.ui.utils import QmlInstantEngine\nfrom meshroom.ui import commands\n\n\nclass FileStatus(Enum):\n    MISSING=0\n    EXISTS=1\n    ERROR=2  # If the file exists but have errors like missing nodes, file content corruption...\n\n\nclass MessageHandler:\n    \"\"\"\n    MessageHandler that translates Qt logs to Python logging system.\n    Also contains and filters a list of blacklisted QML warnings that end up in the\n    standard error even when setOutputWarningsToStandardError is set to false on the engine.\n    \"\"\"\n\n    outputQmlWarnings = bool(os.environ.get(\"MESHROOM_OUTPUT_QML_WARNINGS\", False))\n\n    logFunctions = {\n        QtMsgType.QtDebugMsg: logging.debug,\n        QtMsgType.QtWarningMsg: logging.warning,\n        QtMsgType.QtInfoMsg: logging.info,\n        QtMsgType.QtFatalMsg: logging.fatal,\n        QtMsgType.QtCriticalMsg: logging.critical,\n        QtMsgType.QtSystemMsg: logging.critical\n    }\n\n    # Warnings known to be inoffensive and related to QML but not silenced\n    # even when 'MESHROOM_OUTPUT_QML_WARNINGS' is set to False\n    qmlWarningsBlacklist = (\n        'Failed to download scene at QUrl(\"\")',\n        'QVariant(Invalid) Please check your QParameters',\n        'Texture will be invalid for this frame',\n    )\n\n    @classmethod\n    def handler(cls, messageType, context, message):\n        \"\"\" Message handler remapping Qt logs to Python logging system. \"\"\"\n\n        if not cls.outputQmlWarnings:\n            # If MESHROOM_OUTPUT_QML_WARNINGS is not set and an error in qml files happen we are\n            # left without any output except \"QQmlApplicationEngine failed to load component\".\n            # This is extremely hard to debug to someone who does not know about\n            # MESHROOM_OUTPUT_QML_WARNINGS beforehand because by default Qml will output errors to\n            # stdout.\n            if \"QQmlApplicationEngine failed to load component\" in message:\n                logging.warning(\"Set MESHROOM_OUTPUT_QML_WARNINGS=1 to get a detailed error message.\")\n\n            # discard blacklisted Qt messages related to QML when 'output qml warnings' is not enabled\n            elif any(w in message for w in cls.qmlWarningsBlacklist):\n                return\n        MessageHandler.logFunctions[messageType](message)\n\ndef createMeshroomParser(args):\n\n    # Create the main parser with a description\n    parser = argparse.ArgumentParser(\n        prog='meshroom',\n        description='Launch Meshroom UI - The toolbox that connects research, industry and community at large.',\n        add_help=True,\n        formatter_class=argparse.RawTextHelpFormatter,\n        epilog='''\nExamples:\n  1. Open an existing project in Meshroom:\n     meshroom myProject.mg\n\n  2. Open a new project in Meshroom with a specific pipeline, import images from a folder and save the project:\n     meshroom -p photogrammetry -i /path/to/images/ --save /path/to/store/the/project.mg\n\n  3. Process a pipeline in command line:\n     meshroom_batch -p cameraTracking -i /input/path -o /output/path -s /path/to/store/the/project.mg\n     See 'meshroom_batch -h' for more details.\n\nAdditional Resources:\n  Website:      https://alicevision.org\n  Manual:       https://meshroom-manual.readthedocs.io\n  Forum:        https://groups.google.com/g/alicevision\n  Tutorials:    https://www.youtube.com/c/AliceVisionOrg\n  Contribute:   https://github.com/alicevision/Meshroom\n'''\n    )\n\n    # Positional Arguments\n    parser.add_argument(\n        'project',\n        metavar='PROJECT',\n        type=str,\n        nargs='?',\n        help='Meshroom project file (e.g. myProject.mg) or folder with images to reconstruct.'\n    )\n\n    # General Options\n    general_group = parser.add_argument_group('General Options')\n    general_group.add_argument(\n        '-v', '--verbose',\n        help='Set the verbosity level for logging:\\n'\n             '  - fatal: Show only critical errors.\\n'\n             '  - error: Show errors only.\\n'\n             '  - warning: Show warnings and errors.\\n'\n             '  - info: Show standard informational messages.\\n'\n             '  - debug: Show detailed debug information.\\n'\n             '  - trace: Show all messages, including trace-level details.',\n        default=os.environ.get('MESHROOM_VERBOSE', 'warning'),\n        choices=['fatal', 'error', 'warning', 'info', 'debug', 'trace'],\n    )\n    general_group.add_argument(\n        '--submitLabel',\n        metavar='SUBMITLABEL',\n        type=str,\n        help='Label of a node when submitted on renderfarm.',\n        default=os.environ.get('MESHROOM_SUBMIT_LABEL', '[Meshroom] {projectName}'),\n    )\n\n    # Project and Input Options\n    project_group = parser.add_argument_group('Project and Input Options')\n    project_group.add_argument(\n        '-i', '--import',\n        metavar='IMAGES/FOLDERS',\n        type=str,\n        nargs='*',\n        help='Import images or a folder with images to process.'\n    )\n    project_group.add_argument(\n        '-I', '--importRecursive',\n        metavar='FOLDERS',\n        type=str,\n        nargs='*',\n        help='Import images to process from specified folder and sub-folders.'\n    )\n    project_group.add_argument(\n        '-s', '--save',\n        metavar='PROJECT.mg',\n        type=str,\n        required=False,\n        help='Save the created scene to the specified Meshroom project file.'\n    )\n    project_group.add_argument(\n        '-1', '--latest',\n        action='store_true',\n        help='Load the most recent scene (-2 and -3 to load the previous ones).'\n    )\n    project_group.add_argument(\n        '-2', '--latest2',\n        action='store_true',\n        help=argparse.SUPPRESS  # This hides the option from the help message\n    )\n    project_group.add_argument(\n        '-3', '--latest3',\n        action='store_true',\n        help=argparse.SUPPRESS  # This hides the option from the help message\n    )\n    project_group.add_argument(\n        '-o', '--output',\n        metavar='OUTPUT_FOLDER',\n        type=str,\n        required=False,\n        nargs='*',\n        help='Set the output folder for the CopyFiles nodes.'\n    )\n\n    # Pipeline Options\n    pipeline_group = parser.add_argument_group('Pipeline Options')\n    pipeline_group.add_argument(\n        '-p', '--pipeline',\n        metavar='FILE.mg / PIPELINE',\n        type=str,\n        default=os.environ.get('MESHROOM_DEFAULT_PIPELINE', ''),\n        help='Select the default Meshroom pipeline:\\n'\n        + '\\n'.join(['    - ' + p for p in meshroom.core.pipelineTemplates]),\n    )\n\n    advanced_group = parser.add_argument_group(\"Advanced Options\")\n    advanced_group.add_argument(\n        \"--env-help\",\n        action=EnvVarHelpAction,\n        nargs=0,\n        help=EnvVarHelpAction.DEFAULT_HELP,\n    )\n\n    return parser.parse_args(args[1:])\n\n\nclass MeshroomApp(QApplication):\n    \"\"\" Meshroom UI Application. \"\"\"\n    def __init__(self, inputArgs):\n        meshroom.core.initPipelines()\n\n        args = createMeshroomParser(inputArgs)\n        qtArgs = []\n\n        if EnvVar.get(EnvVar.MESHROOM_QML_DEBUG):\n            debuggerParams = EnvVar.get(EnvVar.MESHROOM_QML_DEBUG_PARAMS)\n            self.debugger = QQmlDebuggingEnabler(printWarning=True)\n            qtArgs = [f\"-qmljsdebugger={debuggerParams}\"]\n\n        logging.getLogger().setLevel(meshroom.logStringToPython[args.verbose])\n\n        super().__init__(inputArgs[:1] + qtArgs)\n\n        self.setOrganizationName('AliceVision')\n        self.setApplicationName('Meshroom')\n        self.setApplicationVersion(meshroom.__version_label__)\n\n        font = self.font()\n        font.setPointSize(9)\n        self.setFont(font)\n\n        # Use Fusion style by default.\n        QQuickStyle.setStyle(\"Fusion\")\n\n        pwd = os.path.dirname(__file__)\n        self.setWindowIcon(QIcon(os.path.join(pwd, \"img/meshroom.svg\")))\n\n        # Initialize thumbnail cache:\n        # - read related environment variables\n        # - clean cache directory and make sure it exists on disk\n        ThumbnailCache.initialize()\n\n        meshroom.core.initPlugins()\n        meshroom.core.initNodes()\n        meshroom.core.initSubmitters()\n\n        # Initialize the list of recent project files\n        self._recentProjectFiles = self._getRecentProjectFilesFromSettings()\n        # Flag set to True if, for all the project files in the list, thumbnails have been retrieved when they\n        # are available. If set to False, then all the paths in the list are accurate, but some thumbnails might\n        # be retrievable\n        self._updatedRecentProjectFilesThumbnails = True\n\n        # Register components for QML before instantiating the engine\n        components.registerTypes()\n\n        # QML engine setup\n        qmlDir = os.path.join(pwd, \"qml\")\n        url = os.path.join(qmlDir, \"main.qml\")\n        self.engine = QmlInstantEngine()\n        self.engine.addFilesFromDirectory(qmlDir, recursive=True)\n        self.engine.setWatching(os.environ.get(\"MESHROOM_INSTANT_CODING\", False))\n        # whether to output qml warnings to stderr (disable by default)\n        self.engine.setOutputWarningsToStandardError(MessageHandler.outputQmlWarnings)\n        if QtCore.__version_info__ < (5, 14, 2):\n            # After 5.14.1, it gets stuck during logging\n            qInstallMessageHandler(MessageHandler.handler)\n\n        self.engine.addImportPath(qmlDir)\n\n        # expose available node types that can be instantiated\n        self.engine.rootContext().setContextProperty(\"_nodeTypes\", {n: {\"category\": pluginManager.getRegisteredNodePlugins()[n].nodeDescriptor.category} for n in sorted(pluginManager.getRegisteredNodePlugins().keys())})\n\n        # instantiate the 3D Scene object\n        self._undoStack = commands.UndoStack(self)\n        self._defaultSubmitterName = os.environ.get('MESHROOM_DEFAULT_SUBMITTER', '')\n        self._taskManager = TaskManager(self)\n        self._activeProject = Scene(undoStack=self._undoStack, taskManager=self._taskManager, defaultPipeline=args.pipeline, parent=self)\n        self._activeProject.setSubmitLabel(args.submitLabel)\n        self.engine.rootContext().setContextProperty(\"_currentScene\", self._activeProject)\n\n        # those helpers should be available from QML Utils module as singletons, but:\n        #  - qmlRegisterUncreatableType is not yet available in PySide2\n        #  - declaring them as singleton in qmldir file causes random crash at exit\n        # => expose them as context properties instead\n        self.engine.rootContext().setContextProperty(\"Filepath\", FilepathHelper(parent=self))\n        self.engine.rootContext().setContextProperty(\"Scene3DHelper\", Scene3DHelper(parent=self))\n        self.engine.rootContext().setContextProperty(\"Transformations3DHelper\", Transformations3DHelper(parent=self))\n        self.engine.rootContext().setContextProperty(\"Clipboard\", ClipboardHelper(parent=self))\n        self.engine.rootContext().setContextProperty(\"ThumbnailCache\", ThumbnailCache(parent=self))\n        self.engine.rootContext().setContextProperty(\"ShapeFilesHelper\", ShapeFilesHelper(self.activeProject, parent=self))\n        self.engine.rootContext().setContextProperty(\"ShapeViewerHelper\", ShapeViewerHelper(parent=self))\n\n        # additional context properties\n        self._messageController = MessageController(parent=self)\n        self.engine.rootContext().setContextProperty(\"_messageController\", self._messageController)\n        self.engine.rootContext().setContextProperty(\"_PaletteManager\", PaletteManager(self.engine, parent=self))\n        self.engine.rootContext().setContextProperty(\"ScriptEditorManager\", ScriptEditorManager(parent=self))\n        self.engine.rootContext().setContextProperty(\"MeshroomApp\", self)\n\n        # request any potential computation to stop on exit\n        self.aboutToQuit.connect(self._activeProject.stopChildThreads)\n\n        if args.project and not os.path.isfile(args.project):\n            raise RuntimeError(\n                \"Meshroom Command Line Error: 'PROJECT' argument should be a Meshroom project file (.mg).\\n\"\n                \"Invalid value: '{}'\".format(args.project))\n\n        if args.project:\n            args.project = os.path.abspath(args.project)\n            self._activeProject.load(args.project)\n            self.addRecentProjectFile(args.project)\n        elif args.latest or args.latest2 or args.latest3:\n            projects = self._recentProjectFiles\n            if projects:\n                index = [args.latest, args.latest2, args.latest3].index(True)\n                project = os.path.abspath(projects[index][\"path\"])\n                self._activeProject.load(project)\n                self.addRecentProjectFile(project)\n        elif getattr(args, \"import\", None) or args.importRecursive or args.save or args.pipeline:\n            if args.output:\n                # Initialize the template and keep the \"CopyFiles\" nodes\n                self._activeProject.newWithCopyOutputs()\n                # Use the provided output paths to initialize the \"CopyFiles\" nodes\n                copyNodes = self._activeProject.graph.nodesOfType(\"CopyFiles\")\n                if len(copyNodes) > 0:\n                    for index, node in enumerate(copyNodes):\n                        node.output.value = args.output[index] if index < len(args.output) else args.output[0]\n            else:\n                self._activeProject.new()\n\n        # import is a python keyword, so we have to access the attribute by a string\n        if getattr(args, \"import\", None):\n            self._activeProject.importImagesFromFolder(getattr(args, \"import\"), recursive=False)\n\n        if args.importRecursive:\n            self._activeProject.importImagesFromFolder(args.importRecursive, recursive=True)\n\n        if args.save:\n            if os.path.isfile(args.save):\n                raise RuntimeError(\n                    \"Meshroom Command Line Error: Cannot save the new Meshroom project as the file (.mg) already exists.\\n\"\n                    \"Invalid value: '{}'\".format(args.save))\n            projectFolder = os.path.dirname(args.save)\n            if not os.path.isdir(projectFolder):\n                if not os.path.isdir(os.path.dirname(projectFolder)):\n                    raise RuntimeError(\n                        \"Meshroom Command Line Error: Cannot save the new Meshroom project file (.mg) as the parent of the folder does not exists.\\n\"\n                        \"Invalid value: '{}'\".format(args.save))\n                os.mkdir(projectFolder)\n            self._activeProject.saveAs(args.save)\n            self.addRecentProjectFile(args.save)\n\n        self.engine.load(os.path.normpath(url))\n\n    def terminateManual(self):\n        self.engine.clearComponentCache()\n        self.engine.collectGarbage()\n        self.engine.deleteLater()\n\n    def _pipelineTemplateFiles(self):\n        templates = []\n        for key in sorted(meshroom.core.pipelineTemplates.keys()):\n            # Use uppercase letters in the names as separators to format the templates' name nicely\n            # e.g: the template \"panoramaHdr\" will be shown as \"Panorama Hdr\" in the menu\n            name = \" \".join(re.findall('[A-Z][^A-Z]*', key[0].upper() + key[1:]))\n            variant = {\"name\": name, \"key\": key, \"path\": meshroom.core.pipelineTemplates[key]}\n            templates.append(variant)\n        return templates\n\n    def _pipelineTemplateNames(self):\n        return [p[\"name\"] for p in self.pipelineTemplateFiles]\n\n    @Slot()\n    def reloadTemplateList(self):\n        meshroom.core.initPipelines()\n        self.pipelineTemplateFilesChanged.emit()\n\n    @Slot()\n    def forceUIUpdate(self):\n        \"\"\" Force UI to process pending events\n        Necessary when we want to update the UI while a trigger is still running (e.g. reloadPlugins)\n        \"\"\"\n        self.processEvents()\n\n    def showMessage(self, message, status=None, duration=5000):\n        self._messageController.sendMessage(message, status, duration)\n\n    def _retrieveThumbnailPath(self, filepath: str) -> str:\n        \"\"\"\n        Given the path of a project file, load this file and try to retrieve the path to its thumbnail, i.e. its\n        first viewpoint image.\n\n        Args:\n            filepath: the path of the project file to retrieve the thumbnail from\n\n        Returns:\n            The path to the thumbnail if it could be found, an empty string otherwise\n        \"\"\"\n        try:\n            with open(filepath) as file:\n                fileData = json.load(file)\n\n            graphData = fileData.get(\"graph\", {})\n            for node in graphData.values():\n                if node.get(\"nodeType\") != \"CameraInit\":\n                    continue\n                if viewpoints := node.get(\"inputs\", {}).get(\"viewpoints\"):\n                    return viewpoints[0].get(\"path\", \"\")\n\n        except FileNotFoundError:\n            logging.info(f\"File {filepath} not found on disk.\")\n        except (json.JSONDecodeError, UnicodeDecodeError):\n            logging.info(f\"Error while loading file {filepath}.\")\n        except KeyError as err:\n            logging.info(f\"The following key does not exist: {str(err)}\")\n        except Exception as err:\n            logging.info(f\"Exception: {str(err)}\")\n\n        return \"\"\n\n    def _getRecentProjectFilesFromSettings(self) -> list[dict[str, str]]:\n        \"\"\"\n        Read the list of recent project files from the QSettings, retrieve their filepath, and if it exists, their\n        thumbnail.\n\n        Returns:\n            The list containing dictionaries of the form {\"path\": \"/path/to/project/file\", \"thumbnail\":\n            \"/path/to/thumbnail\"} based on the recent projects stored in the QSettings.\n        \"\"\"\n        projects = []\n        settings = QSettings()\n        settings.beginGroup(\"RecentFiles\")\n        size = settings.beginReadArray(\"Projects\")\n        for i in range(size):\n            settings.setArrayIndex(i)\n            path = settings.value(\"filepath\")\n            if path:\n                fileStatus = FileStatus.EXISTS if os.path.isfile(path) else FileStatus.MISSING\n                p = {\"path\": path, \"thumbnail\": self._retrieveThumbnailPath(path), \"status\": fileStatus.value}\n                projects.append(p)\n        settings.endArray()\n        settings.endGroup()\n        return projects\n\n    @Slot()\n    def updateRecentProjectFilesThumbnails(self) -> None:\n        \"\"\"\n        If there are thumbnails that may be retrievable (meaning the list of projects has been updated minimally),\n        update the list of recent project files by reading the QSettings and retrieving the thumbnails if they are\n        available.\n        \"\"\"\n        if not self._updatedRecentProjectFilesThumbnails:\n            self._updateRecentProjectFilesThumbnails()\n            self._updatedRecentProjectFilesThumbnails = True\n\n    def _updateRecentProjectFilesThumbnails(self) -> None:\n        for project in self._recentProjectFiles:\n            path = project[\"path\"]\n            project[\"thumbnail\"] = self._retrieveThumbnailPath(path)\n            project[\"status\"] = os.path.isfile(path)\n\n    @Slot(str)\n    @Slot(QUrl)\n    def addRecentProjectFile(self, projectFile) -> None:\n        \"\"\"\n        Add a project file to the list of recent project files.\n        The function ensures that the file is not present more than once in the list and trims it so it\n        never exceeds a set number of projects.\n        QSettings are updated accordingly.\n        The update of the list of recent projects files is minimal: the filepath is added, but there is no\n        attempt to retrieve its corresponding thumbnail.\n\n        Args:\n            projectFile (str or QUrl): path to the project file to add to the list\n        \"\"\"\n        if not isinstance(projectFile, (QUrl, str)):\n            raise TypeError(f\"Unexpected data type: {projectFile.__class__}\")\n        if isinstance(projectFile, QUrl):\n            projectFileNorm = projectFile.toLocalFile()\n            if not projectFileNorm:\n                projectFileNorm = projectFile.toString()\n        else:\n            projectFileNorm = QUrl(projectFile).toLocalFile()\n            if not projectFileNorm:\n                projectFileNorm = QUrl.fromLocalFile(projectFile).toLocalFile()\n\n        # Get the list of recent projects without re-reading the QSettings\n        projects = self._recentProjectFiles\n\n        # Checks whether the path is already in the list of recent projects\n        filepaths = [p[\"path\"] for p in projects]\n        if projectFileNorm in filepaths:\n            idx = filepaths.index(projectFileNorm)\n            del projects[idx]  # If so, delete its entry\n\n        # Insert the newest entry at the top of the list\n        projects.insert(0, {\"path\": projectFileNorm, \"thumbnail\": \"\", \"status\": FileStatus.EXISTS})\n\n        # Only keep the first 40 projects\n        maxNbProjects = 40\n        if len(projects) > maxNbProjects:\n            projects = projects[0:maxNbProjects]\n\n        # Update the general settings\n        settings = QSettings()\n        settings.beginGroup(\"RecentFiles\")\n        settings.beginWriteArray(\"Projects\")\n        for i, p in enumerate(projects):\n            settings.setArrayIndex(i)\n            settings.setValue(\"filepath\", p[\"path\"])\n        settings.endArray()\n        settings.endGroup()\n        settings.sync()\n\n        # Update the final list of recent projects\n        self._recentProjectFiles = projects\n        self._updatedRecentProjectFilesThumbnails = False  # Thumbnails may not be up-to-date\n        self.recentProjectFilesChanged.emit()\n\n    @Slot(str)\n    @Slot(QUrl)\n    def removeRecentProjectFile(self, projectFile) -> None:\n        \"\"\"\n        Remove a given project file from the list of recent project files.\n        If the provided filepath is not already present in the list of recent project files, nothing is done.\n        Otherwise, it is effectively removed and the QSettings are updated accordingly.\n        \"\"\"\n        if not isinstance(projectFile, (QUrl, str)):\n            raise TypeError(f\"Unexpected data type: {projectFile.__class__}\")\n        if isinstance(projectFile, QUrl):\n            projectFileNorm = projectFile.toLocalFile()\n            if not projectFileNorm:\n                projectFileNorm = projectFile.toString()\n        else:\n            projectFileNorm = QUrl(projectFile).toLocalFile()\n            if not projectFileNorm:\n                projectFileNorm = QUrl.fromLocalFile(projectFile).toLocalFile()\n\n        # Get the list of recent projects without re-reading the QSettings\n        projects = self._recentProjectFiles\n\n        # Ensure the filepath is in the list of recent projects\n        filepaths = [p[\"path\"] for p in projects]\n        if projectFileNorm not in filepaths:\n            return\n\n        # Delete it from the list\n        idx = filepaths.index(projectFileNorm)\n        del projects[idx]\n\n        # Update the general settings\n        settings = QSettings()\n        settings.beginGroup(\"RecentFiles\")\n        settings.beginWriteArray(\"Projects\")\n        for i, p in enumerate(projects):\n            settings.setArrayIndex(i)\n            settings.setValue(\"filepath\", p[\"path\"])\n        settings.endArray()\n        settings.sync()\n\n        # Update the final list of recent projects\n        self._recentProjectFiles = projects\n        self.recentProjectFilesChanged.emit()\n\n    def _recentImportedImagesFolders(self):\n        folders = []\n        settings = QSettings()\n        settings.beginGroup(\"RecentFiles\")\n        size = settings.beginReadArray(\"ImagesFolders\")\n        for i in range(size):\n            settings.setArrayIndex(i)\n            f = settings.value(\"path\")\n            if f:\n                folders.append(f)\n        settings.endArray()\n        return folders\n\n    @Slot(QUrl)\n    def addRecentImportedImagesFolder(self, imagesFolder):\n        if isinstance(imagesFolder, QUrl):\n            folderPath = imagesFolder.toLocalFile()\n            if not folderPath:\n                folderPath = imagesFolder.toString()\n        else:\n            raise TypeError(f\"Unexpected data type: {imagesFolder.__class__}\")\n\n        folders = self._recentImportedImagesFolders()\n\n        # remove duplicates while preserving order\n        from collections import OrderedDict\n        uniqueFolders = OrderedDict.fromkeys(folders)\n        folders = list(uniqueFolders)\n        # remove previous usage of the value\n        if folderPath in uniqueFolders:\n            folders.remove(folderPath)\n        # add the new value in the first place\n        folders.insert(0, folderPath)\n\n        # keep only the first three elements to have a backup if one of the folders goes missing\n        folders = folders[0:3]\n\n        settings = QSettings()\n        settings.beginGroup(\"RecentFiles\")\n        settings.beginWriteArray(\"ImagesFolders\")\n        for i, p in enumerate(folders):\n            settings.setArrayIndex(i)\n            settings.setValue(\"path\", p)\n        settings.endArray()\n        settings.sync()\n\n        self.recentImportedImagesFoldersChanged.emit()\n\n    @Slot(QUrl)\n    def removeRecentImportedImagesFolder(self, imagesFolder):\n        if isinstance(imagesFolder, QUrl):\n            folderPath = imagesFolder.toLocalFile()\n            if not folderPath:\n                folderPath = imagesFolder.toString()\n        else:\n            raise TypeError(f\"Unexpected data type: {imagesFolder.__class__}\")\n\n        folders = self._recentImportedImagesFolders()\n\n        # remove duplicates while preserving order\n        from collections import OrderedDict\n        uniqueFolders = OrderedDict.fromkeys(folders)\n        folders = list(uniqueFolders)\n        # remove previous usage of the value\n        if folderPath not in uniqueFolders:\n            return\n\n        folders.remove(folderPath)\n\n        settings = QSettings()\n        settings.beginGroup(\"RecentFiles\")\n        settings.beginWriteArray(\"ImagesFolders\")\n        for i, f in enumerate(folders):\n            settings.setArrayIndex(i)\n            settings.setValue(\"path\", f)\n        settings.endArray()\n        settings.sync()\n\n        self.recentImportedImagesFoldersChanged.emit()\n\n    @Slot(str, result=str)\n    def markdownToHtml(self, md):\n        \"\"\"\n        Convert markdown to HTML.\n\n        Args:\n            md (str): the markdown text to convert\n\n        Returns:\n            str: the resulting HTML string\n        \"\"\"\n        try:\n            from markdown import markdown\n        except ImportError:\n            logging.warning(\"Can't import markdown module, returning source markdown text.\")\n            return md\n        return markdown(md)\n\n    def _systemInfo(self):\n        import platform\n        import sys\n        return {\n            'platform': f'{platform.system()} {platform.release()}',\n            'python': f\"Python {sys.version.split(' ')[0]}\",\n            'pyside': f'PySide6 {PySideVersion}'\n        }\n\n    systemInfo = Property(QJsonValue, _systemInfo, constant=True)\n\n    def _changelogModel(self):\n        \"\"\"\n        Get the complete changelog for the application.\n        Model provides:\n            title: the name of the changelog\n            localUrl: the local path to CHANGES.md\n            onlineUrl: the remote path to CHANGES.md\n        \"\"\"\n        rootDir = os.environ.get(\"MESHROOM_INSTALL_DIR\", os.getcwd())\n        return [\n            {\n                \"title\": \"Changelog\",\n                \"localUrl\": os.path.join(rootDir, \"CHANGES.md\"),\n                \"onlineUrl\": \"https://raw.githubusercontent.com/alicevision/meshroom/develop/CHANGES.md\"\n            }\n        ]\n\n    def _licensesModel(self):\n        \"\"\"\n        Get info about open-source licenses for the application.\n        Model provides:\n            title: the name of the project\n            localUrl: the local path to COPYING.md\n            onlineUrl: the remote path to COPYING.md\n        \"\"\"\n        rootDir = os.environ.get(\"MESHROOM_INSTALL_DIR\", os.getcwd())\n        return [\n            {\n                \"title\": \"Meshroom\",\n                \"localUrl\": os.path.join(rootDir, \"COPYING.md\"),\n                \"onlineUrl\": \"https://raw.githubusercontent.com/alicevision/meshroom/develop/COPYING.md\"\n            },\n            {\n                \"title\": \"AliceVision\",\n                \"localUrl\": os.path.join(rootDir, \"aliceVision\", \"share\", \"aliceVision\", \"COPYING.md\"),\n                \"onlineUrl\": \"https://raw.githubusercontent.com/alicevision/AliceVision/develop/COPYING.md\"\n            }\n        ]\n\n    def _default8bitViewerEnabled(self):\n        return self._getEnvironmentVariableValue(\"MESHROOM_USE_8BIT_VIEWER\", False)\n\n    def _defaultSequencePlayerEnabled(self):\n        return self._getEnvironmentVariableValue(\"MESHROOM_USE_SEQUENCE_PLAYER\", True)\n\n    def _getEnvironmentVariableValue(self, key: str, defaultValue: bool) -> bool:\n        \"\"\"\n        Fetch the value of a provided environment variable if it exists, and ensure it is correctly\n        evaluated.\n\n        Args:\n            key: the key for the environment variable\n            defaultValue: the value to use if the key does not exist\n        \"\"\"\n        val = os.environ.get(key, defaultValue)\n        # os.environ.get returns a string if the key exists, no matter its value, and converting a\n        # string to a bool always evaluates to \"True\"\n        if val != True and str(val).lower() in (\"0\", \"false\", \"off\"):\n            return False\n        return True\n\n    def _submittersList(self):\n        \"\"\"\n        Get the list of available submitters\n        Model provides :\n            name : the name of the submitter\n            isDefault : True if this is the current submitter\n        \"\"\"\n        submittersList = []\n        for i, s in enumerate(meshroom.core.submitters):\n            submitterName = s.name if isinstance(s, BaseSubmitter) else s\n            # If no explicit default submitter, this will be the first one\n            isDefault = (i == 0)\n            if self._defaultSubmitterName:\n                isDefault = (submitterName == self._defaultSubmitterName)\n            submittersList.append({\n                \"name\": submitterName,\n                \"isDefault\": isDefault\n            })\n        return submittersList\n\n    @Slot(str)\n    def setDefaultSubmitter(self, name):\n        logging.info(f\"Submitter is now set to : {name}\")\n        self._defaultSubmitterName = name\n\n    activeProjectChanged = Signal()\n    activeProject = Property(Variant, lambda self: self._activeProject, notify=activeProjectChanged)\n\n    changelogModel = Property(\"QVariantList\", _changelogModel, constant=True)\n    licensesModel = Property(\"QVariantList\", _licensesModel, constant=True)\n    pipelineTemplateFilesChanged = Signal()\n    recentProjectFilesChanged = Signal()\n    recentImportedImagesFoldersChanged = Signal()\n    pipelineTemplateFiles = Property(\"QVariantList\", _pipelineTemplateFiles, notify=pipelineTemplateFilesChanged)\n    pipelineTemplateNames = Property(\"QVariantList\", _pipelineTemplateNames, notify=pipelineTemplateFilesChanged)\n    recentProjectFiles = Property(\"QVariantList\", lambda self: self._recentProjectFiles, notify=recentProjectFilesChanged)\n    recentImportedImagesFolders = Property(\"QVariantList\", _recentImportedImagesFolders, notify=recentImportedImagesFoldersChanged)\n    default8bitViewerEnabled = Property(bool, _default8bitViewerEnabled, constant=True)\n    defaultSequencePlayerEnabled = Property(bool, _defaultSequencePlayerEnabled, constant=True)\n    submittersListModel = Property(\"QVariantList\", _submittersList, constant=True)\n"
  },
  {
    "path": "meshroom/ui/commands.py",
    "content": "import logging\nimport traceback\nfrom contextlib import contextmanager\n\nfrom PySide6.QtGui import QUndoCommand, QUndoStack\nfrom PySide6.QtCore import Property, Signal\n\nfrom meshroom.core.attribute import ListAttribute, Attribute\nfrom meshroom.core.exception import CyclicDependencyError,InvalidEdgeError\nfrom meshroom.core.graph import Graph, GraphModification\nfrom meshroom.core.node import Position, CompatibilityIssue\nfrom meshroom.core.nodeFactory import nodeFactory\nfrom meshroom.core.mtyping import PathLike\n\n\nclass UndoCommand(QUndoCommand):\n    def __init__(self, parent=None):\n        super().__init__(parent)\n        self._enabled = True\n\n    def setEnabled(self, enabled):\n        self._enabled = enabled\n\n    def redo(self):\n        if not self._enabled:\n            return\n        try:\n            self.redoImpl()\n        except Exception:\n            logging.error(f\"Error while redoing command '{self.text()}': \\n{traceback.format_exc()}\")\n\n    def undo(self):\n        if not self._enabled:\n            return\n        try:\n            self.undoImpl()\n        except Exception:\n            logging.error(f\"Error while undoing command '{self.text()}': \\n{traceback.format_exc()}\")\n\n    def redoImpl(self):\n        # type: () -> bool\n        pass\n\n    def undoImpl(self):\n        # type: () -> bool\n        pass\n\n\nclass UndoStack(QUndoStack):\n    def __init__(self, parent=None):\n        super().__init__(parent)\n        # connect QUndoStack signal to UndoStack's ones\n        self.cleanChanged.connect(self._cleanChanged)\n        self.canUndoChanged.connect(self._canUndoChanged)\n        self.canRedoChanged.connect(self._canRedoChanged)\n        self.undoTextChanged.connect(self._undoTextChanged)\n        self.redoTextChanged.connect(self._redoTextChanged)\n        self.indexChanged.connect(self._indexChanged)\n\n        self._undoableIndex = 0  # used to block the undo stack while computing\n        self._lockedRedo = False  # used to avoid unwanted behaviors while computing\n\n    def tryAndPush(self, command):\n        # type: (UndoCommand) -> bool\n        try:\n            res = command.redoImpl()\n        except Exception:\n            logging.error(f\"Error while trying command '{command.text()}': \\n{traceback.format_exc()}\")\n            res = False\n        if res is not False:\n            command.setEnabled(False)\n            self.push(command)  # takes ownership\n            self.setLockedRedo(False)  # make sure to unlock the redo action\n            command.setEnabled(True)\n        return res\n\n    def setUndoableIndex(self, value):\n        if self._undoableIndex == value:\n            return\n        self._undoableIndex = value\n        self.isUndoableIndexChanged.emit()\n\n    def setLockedRedo(self, value):\n        if self._lockedRedo == value:\n            return\n        self._lockedRedo = value\n        self.lockedRedoChanged.emit()\n\n    def lockAtThisIndex(self):\n        \"\"\"\n        Lock the undo stack at the current index and lock the redo action.\n        Note: should be used while starting a new compute to avoid problems.\n        \"\"\"\n        self.setUndoableIndex(self.index)\n        self.setLockedRedo(True)\n\n    def unlock(self):\n        \"\"\" Unlock both undo stack and redo action. \"\"\"\n        self.setUndoableIndex(0)\n        self.setLockedRedo(False)\n\n    # Redeclare QUndoStack signal since original ones can not be used for properties notifying\n    _cleanChanged = Signal()\n    _canUndoChanged = Signal()\n    _canRedoChanged = Signal()\n    _undoTextChanged = Signal()\n    _redoTextChanged = Signal()\n    _indexChanged = Signal()\n\n    clean = Property(bool, QUndoStack.isClean, notify=_cleanChanged)\n    canUndo = Property(bool, QUndoStack.canUndo, notify=_canRedoChanged)\n    canRedo = Property(bool, QUndoStack.canRedo, notify=_canUndoChanged)\n    undoText = Property(str, QUndoStack.undoText, notify=_undoTextChanged)\n    redoText = Property(str, QUndoStack.redoText, notify=_redoTextChanged)\n    index = Property(int, QUndoStack.index, notify=_indexChanged)\n\n    isUndoableIndexChanged = Signal()\n    isUndoableIndex = Property(bool, lambda self: self.index > self._undoableIndex, notify=isUndoableIndexChanged)\n    lockedRedoChanged = Signal()\n    lockedRedo = Property(bool, lambda self: self._lockedRedo, setLockedRedo, notify=lockedRedoChanged)\n\n\nclass GraphCommand(UndoCommand):\n    def __init__(self, graph, parent=None):\n        super().__init__(parent)\n        self.graph = graph\n\n\nclass AddNodeCommand(GraphCommand):\n    def __init__(self, graph, nodeType, position, parent=None, **kwargs):\n        super().__init__(graph, parent)\n        self.nodeType = nodeType\n        self.nodeName = None\n        self.position = position\n        self.kwargs = kwargs\n        # Serialize Attributes as link expressions\n        for key, value in self.kwargs.items():\n            if isinstance(value, Attribute):\n                self.kwargs[key] = value.asLinkExpr()\n            elif isinstance(value, list):\n                for idx, v in enumerate(value):\n                    if isinstance(v, Attribute):\n                         value[idx] = v.asLinkExpr()\n\n    def redoImpl(self):\n        node = self.graph.addNewNode(self.nodeType, position=self.position, **self.kwargs)\n        self.nodeName = node.name\n        self.setText(f\"Add Node {self.nodeName}\")\n        return node\n\n    def undoImpl(self):\n        self.graph.removeNode(self.nodeName)\n\n\nclass RenameNodeCommand(GraphCommand):\n    def __init__(self, graph, node, name, parent=None):\n        \"\"\" Command to rename a node. The new name should not be used yet.\n        \"\"\"\n        super().__init__(graph, parent)\n        self.node = node\n        self.oldName = node._name\n        self.name = name\n\n    def redoImpl(self):\n        self.setText(f\"Rename Node {self.oldName} to {self.name}\")\n        self.graph.renameNode(self.node, self.name)\n        return self.node._name\n\n    def undoImpl(self):\n        self.graph.renameNode(self.node, self.oldName)\n\n\nclass RemoveNodeCommand(GraphCommand):\n    def __init__(self, graph, node, parent=None):\n        super().__init__(graph, parent)\n        self.nodeDict = node.toDict()\n        self.nodeName = node.getName()\n        self.setText(f\"Remove Node {self.nodeName}\")\n        self.outEdges = {}\n        self.outListAttributes = {}  # maps attribute's key with a tuple containing the name of the list it is connected to and its value\n\n    def redoImpl(self):\n        # keep outEdges (inEdges are serialized in nodeDict so unneeded here) and outListAttributes to be able to recreate the deleted elements in ListAttributes\n        _, self.outEdges, self.outListAttributes = self.graph.removeNode(self.nodeName)\n        return True\n\n    def undoImpl(self):\n        with GraphModification(self.graph):\n            node = nodeFactory(self.nodeDict, self.nodeName)\n            self.graph.addNode(node, self.nodeName)\n            assert (node.getName() == self.nodeName)\n            self.graph._restoreOutEdges(self.outEdges, self.outListAttributes)\n\n\nclass DuplicateNodesCommand(GraphCommand):\n    \"\"\"\n    Handle node duplication in a Graph.\n    \"\"\"\n    def __init__(self, graph, srcNodes, parent=None):\n        super().__init__(graph, parent)\n        self.srcNodeNames = [ n.name for n in srcNodes ]\n        self.setText(\"Duplicate Nodes\")\n\n    def redoImpl(self):\n        srcNodes = [ self.graph.node(i) for i in self.srcNodeNames ]\n        # flatten the list of duplicated nodes to avoid lists within the list\n        duplicates = [ n for nodes in list(self.graph.duplicateNodes(srcNodes).values()) for n in nodes ]\n        self.duplicates = [ n.name for n in duplicates ]\n        return duplicates\n\n    def undoImpl(self):\n        # remove all duplicates\n        for duplicate in self.duplicates:\n            self.graph.removeNode(duplicate)\n\n\nclass PasteNodesCommand(GraphCommand):\n    \"\"\"\n    Handle node pasting in a Graph.\n    \"\"\"\n    def __init__(self, graph: \"Graph\", data: dict, position: Position, parent=None):\n        super().__init__(graph, parent)\n        self.data = data\n        self.position = position\n        self.nodeNames: list[str] = []\n\n    def redoImpl(self):\n        graph = Graph(\"\")\n        try:\n            graph._deserialize(self.data)\n        except:\n            return False\n\n        boundingBoxCenter = self._boundingBoxCenter(graph.nodes)\n        offset = Position(self.position.x - boundingBoxCenter.x, self.position.y - boundingBoxCenter.y)\n\n        for node in graph.nodes:\n            node.position = Position(node.position.x + offset.x, node.position.y + offset.y)\n\n        nodes = self.graph.importGraphContent(graph)\n\n        self.nodeNames = [node.name for node in nodes]\n        self.setText(f\"Paste Node{'s' if len(self.nodeNames) > 1 else ''} ({', '.join(self.nodeNames)})\")\n        return nodes\n\n    def undoImpl(self):\n        for name in self.nodeNames:\n            self.graph.removeNode(name)\n\n    def _boundingBox(self, nodes) -> tuple[int, int, int, int]:\n        if not nodes:\n            return (0, 0, 0 , 0)\n\n        minX = maxX = nodes[0].x\n        minY = maxY = nodes[0].y\n\n        for node in nodes[1:]:\n            minX = min(minX, node.x)\n            minY = min(minY, node.y)\n            maxX = max(maxX, node.x)\n            maxY = max(maxY, node.y)\n\n        return (minX, minY, maxX, maxY)\n\n    def _boundingBoxCenter(self, nodes):\n        minX, minY, maxX, maxY = self._boundingBox(nodes)\n        return Position((minX + maxX) / 2, (minY + maxY) / 2)\n\nclass ImportProjectCommand(GraphCommand):\n    \"\"\"\n    Handle the import of a project into a Graph.\n    \"\"\"\n\n    def __init__(self, graph: Graph, filepath: PathLike, position=None, yOffset=0, parent=None):\n        super().__init__(graph, parent)\n        self.filepath = filepath\n        self.importedNames = []\n        self.position = position\n        self.yOffset = yOffset\n\n    def redoImpl(self):\n        importedNodes = self.graph.importGraphContentFromFile(self.filepath)\n        self.setText(f\"Import Project ({len(importedNodes)} nodes)\")\n\n        lowestY = 0\n        for node in self.graph.nodes:\n            if node not in importedNodes and node.y > lowestY:\n                lowestY = node.y\n\n        for node in importedNodes:\n            self.importedNames.append(node.name)\n            if self.position is not None:\n                self.graph.node(node.name).position = Position(node.x + self.position.x, node.y + self.position.y)\n            else:\n                self.graph.node(node.name).position = Position(node.x, node.y + lowestY + self.yOffset)\n\n        return importedNodes\n\n    def undoImpl(self):\n        for nodeName in self.importedNames:\n            self.graph.removeNode(nodeName)\n        self.importedNames = []\n\n\nclass SetAttributeCommand(GraphCommand):\n    def __init__(self, graph, attribute, value, parent=None):\n        super().__init__(graph, parent)\n        self.attrName = attribute.fullName\n        self.value = value\n        self.oldValue = attribute.getSerializedValue()\n        self.setText(f\"Set Attribute '{attribute.fullName}'\")\n\n    def redoImpl(self):\n        if self.value == self.oldValue:\n            return False\n        if self.graph.attribute(self.attrName) is not None:\n            self.graph.attribute(self.attrName).value = self.value\n        else:\n            self.graph.internalAttribute(self.attrName).value = self.value\n        return True\n\n    def undoImpl(self):\n        if self.graph.attribute(self.attrName) is not None:\n            self.graph.attribute(self.attrName).value = self.oldValue\n        else:\n            self.graph.internalAttribute(self.attrName).value = self.oldValue\n\nclass AddAttributeKeyValueCommand(GraphCommand):\n    def __init__(self, graph, attribute, key, value, parent=None):\n        super().__init__(graph, parent)\n        self.attrName = attribute.fullName\n        self.keyable = attribute.keyable\n        self.key = key\n        self.value = value\n        self.oldValue = None\n        if attribute.keyable and attribute.keyValues.hasKey(key):\n             self.oldValue = attribute.keyValues.pairs.get(int(key)).value\n        self.setText(f\"Add (key, value) for attribute '{attribute.fullName}' at key: '{key}'\")\n\n    def redoImpl(self):\n        if not self.keyable or self.value == self.oldValue:\n            return False\n        if self.graph.attribute(self.attrName) is not None:\n            self.graph.attribute(self.attrName).keyValues.add(self.key, self.value)\n        else:\n            self.graph.internalAttribute(self.attrName).keyValues.add(self.key, self.value)\n        return True\n\n    def undoImpl(self):\n        if not self.keyable or self.value == self.oldValue:\n            return False\n        if self.graph.attribute(self.attrName) is not None:\n            if self.oldValue is None:\n                self.graph.attribute(self.attrName).keyValues.remove(self.key)\n            else:\n                self.graph.attribute(self.attrName).keyValues.add(self.key, self.oldValue)\n        else:\n            if self.oldValue is None:\n                self.graph.internalAttribute(self.attrName).keyValues.remove(self.key)\n            else:\n                self.graph.internalAttribute(self.attrName).keyValues.add(self.key, self.oldValue)\n        return True\n\nclass RemoveAttributeKeyCommand(GraphCommand):\n    def __init__(self, graph, attribute, key, parent=None):\n        super().__init__(graph, parent)\n        self.attrName = attribute.fullName\n        self.keyable = attribute.keyable\n        self.key = key\n        self.oldValue = None\n        if attribute.keyable and attribute.keyValues.hasKey(key):\n             self.oldValue = attribute.keyValues.pairs.get(int(key)).value\n        self.setText(f\"Remove (key, value) for attribute '{attribute.fullName}' at key: '{key}'\")\n\n    def redoImpl(self):\n        if not self.keyable or self.oldValue == None:\n            return False\n        if self.graph.attribute(self.attrName) is not None:\n            self.graph.attribute(self.attrName).keyValues.remove(self.key)\n        else:\n            self.graph.internalAttribute(self.attrName).keyValues.remove(self.key)\n        return True\n\n    def undoImpl(self):\n        if not self.keyable or self.oldValue == None:\n            return False\n        if self.graph.attribute(self.attrName) is not None:\n            self.graph.attribute(self.attrName).keyValues.add(self.key, self.oldValue)\n        else:\n            self.graph.internalAttribute(self.attrName).keyValues.add(self.key, self.oldValue)\n        return True\n\nclass SetObservationCommand(GraphCommand):\n    def __init__(self, graph, attribute, key, observation, parent=None):\n        super().__init__(graph, parent)\n        self.attrName = attribute.fullName\n        self.key = key\n        self.observation = observation.toVariant()\n        self.oldObservation = attribute.geometry.getObservation(key)\n        self.setText(f\"Set observation for shape attribute '{attribute.fullName}' at key: '{key}'\")\n\n    def redoImpl(self):\n        if self.graph.attribute(self.attrName) is not None:\n            self.graph.attribute(self.attrName).geometry.setObservation(self.key, self.observation)\n        else:\n            self.graph.internalAttribute(self.attrName).geometry.setObservation(self.key, self.observation)\n        return True\n\n    def undoImpl(self):\n        if self.graph.attribute(self.attrName) is not None:\n            if self.oldObservation is None:\n                self.graph.attribute(self.attrName).geometry.removeObservation(self.key)\n            else:\n                self.graph.attribute(self.attrName).geometry.setObservation(self.key, self.oldObservation)\n        else:\n            if self.oldObservation is None:\n                self.graph.internalAttribute(self.attrName).geometry.removeObservation(self.key)\n            else:\n                self.graph.internalAttribute(self.attrName).geometry.setObservation(self.key, self.oldObservation)\n        return True\n\nclass RemoveObservationCommand(GraphCommand):\n    def __init__(self, graph, attribute, key, parent=None):\n        super().__init__(graph, parent)\n        self.attrName = attribute.fullName\n        self.key = key\n        self.oldObservation = attribute.geometry.getObservation(key)\n        self.setText(f\"Remove observation for shape attribute '{attribute.fullName}' at key: '{key}'\")\n\n    def redoImpl(self):\n        if self.graph.attribute(self.attrName) is not None:\n            self.graph.attribute(self.attrName).geometry.removeObservation(self.key)\n        else:\n            self.graph.internalAttribute(self.attrName).geometry.removeObservation(self.key)\n        return True\n\n    def undoImpl(self):\n        if self.graph.attribute(self.attrName) is not None:\n            self.graph.attribute(self.attrName).geometry.setObservation(self.key, self.oldObservation)\n        else:\n            self.graph.internalAttribute(self.attrName).geometry.setObservation(self.key, self.oldObservation)\n        return True\n\nclass AddEdgeCommand(GraphCommand):\n    def __init__(self, graph, src, dst, parent=None):\n        super().__init__(graph, parent)\n        self.srcAttr = src.fullName\n        self.dstAttr = dst.fullName\n        self.createdEdges = []  # List of all the edges that have been created at once\n        self.deletedEdges = []  # List of all the edges that have been deleted to create the new edge(s)\n        self.setText(f\"Connect '{self.srcAttr}' -> '{self.dstAttr}'\")\n\n        if not dst.validateIncomingConnection(src):\n            raise InvalidEdgeError(src.fullName, dst.fullName, \"Attributes are not compatible.\")\n\n    def redoImpl(self) -> bool:\n        try:\n            self.createdEdges, self.deletedEdges = self.graph.attribute(self.srcAttr).connectTo(self.graph.attribute(self.dstAttr))\n        except CyclicDependencyError:\n            self.graph.removeEdge(self.graph.attribute(self.dstAttr))\n            return False\n        return True\n\n    def undoImpl(self) -> bool:\n        for edge in self.createdEdges:\n            edge[1].disconnectEdge()\n        for edge in self.deletedEdges:\n            edge[0].connectTo(edge[1])\n        return True\n\n\nclass RemoveEdgeCommand(GraphCommand):\n    def __init__(self, graph, edge, parent=None):\n        super().__init__(graph, parent)\n        self.srcAttr = edge.src.fullName\n        self.dstAttr = edge.dst.fullName\n        self.deletedEdges = []  # List of all the edges that have been deleted\n        self.setText(f\"Disconnect '{self.srcAttr}' -> '{self.dstAttr}'\")\n\n    def redoImpl(self) -> bool:\n        self.deletedEdges = self.graph.attribute(self.dstAttr).disconnectEdge()\n        return True\n\n    def undoImpl(self) -> bool:\n        for edge in self.deletedEdges:\n            edge[0].connectTo(edge[1])\n        return True\n\n\nclass ListAttributeAppendCommand(GraphCommand):\n    def __init__(self, graph, listAttribute, value, parent=None):\n        super().__init__(graph, parent)\n        assert isinstance(listAttribute, ListAttribute)\n        self.attrName = listAttribute.fullName\n        self.index = None\n        self.count = 1\n        self.value = value if value else None\n        self.setText(f\"Append to {self.attrName}\")\n\n    def redoImpl(self):\n        listAttribute = self.graph.attribute(self.attrName)\n        self.index = len(listAttribute)\n        if isinstance(self.value, list):\n            listAttribute.extend(self.value)\n            self.count = len(self.value)\n        else:\n            listAttribute.append(self.value)\n        return True\n\n    def undoImpl(self):\n        listAttribute = self.graph.attribute(self.attrName)\n        listAttribute.remove(self.index, self.count)\n\n\nclass ListAttributeRemoveCommand(GraphCommand):\n    def __init__(self, graph, attribute, parent=None):\n        super().__init__(graph, parent)\n        listAttribute = attribute.root\n        assert isinstance(listAttribute, ListAttribute)\n        self.listAttrName = listAttribute.fullName\n        self.index = listAttribute.index(attribute)\n        self.value = attribute.getSerializedValue()\n        self.setText(f\"Remove {attribute.fullName}\")\n\n    def redoImpl(self):\n        listAttribute = self.graph.attribute(self.listAttrName)\n        listAttribute.remove(self.index)\n        return True\n\n    def undoImpl(self):\n        listAttribute = self.graph.attribute(self.listAttrName)\n        listAttribute.insert(self.index, self.value)\n\n\nclass RemoveImagesCommand(GraphCommand):\n    def __init__(self, graph, cameraInitNodes, parent=None):\n        super().__init__(graph, parent)\n        self.cameraInits = cameraInitNodes\n        self.viewpoints = { cameraInit.name: cameraInit.attribute(\"viewpoints\").getSerializedValue() for cameraInit in self.cameraInits }\n        self.intrinsics = { cameraInit.name: cameraInit.attribute(\"intrinsics\").getSerializedValue() for cameraInit in self.cameraInits }\n        self.title = f\"Remove{' ' if len(self.cameraInits) == 1 else ' All '}Images\"\n        self.setText(self.title)\n\n    def redoImpl(self):\n        for i in range(len(self.cameraInits)):\n            # Reset viewpoints\n            self.cameraInits[i].viewpoints.resetToDefaultValue()\n            self.cameraInits[i].viewpoints.valueChanged.emit()\n            self.cameraInits[i].viewpoints.requestGraphUpdate()\n\n            # Reset intrinsics\n            self.cameraInits[i].intrinsics.resetToDefaultValue()\n            self.cameraInits[i].intrinsics.valueChanged.emit()\n            self.cameraInits[i].intrinsics.requestGraphUpdate()\n\n    def undoImpl(self):\n        for cameraInit in self.viewpoints:\n            with GraphModification(self.graph):\n                self.graph.node(cameraInit).viewpoints.value = self.viewpoints[cameraInit]\n                self.graph.node(cameraInit).intrinsics.value = self.intrinsics[cameraInit]\n\n\nclass MoveNodeCommand(GraphCommand):\n    \"\"\" Move a node to a given position. \"\"\"\n    def __init__(self, graph, node, position, parent=None):\n        super().__init__(graph, parent)\n        self.nodeName = node.name\n        self.oldPosition = node.position\n        self.newPosition = position\n        self.setText(f\"Move {self.nodeName}\")\n\n    def redoImpl(self):\n        self.graph.node(self.nodeName).position = self.newPosition\n        return True\n\n    def undoImpl(self):\n        self.graph.node(self.nodeName).position = self.oldPosition\n\n\nclass UpgradeNodeCommand(GraphCommand):\n    \"\"\"\n    Perform node upgrade on a CompatibilityNode.\n    \"\"\"\n    def __init__(self, graph, node, parent=None):\n        super().__init__(graph, parent)\n        self.nodeDict = node.toDict()\n        self.nodeName = node.getName()\n        self.compatibilityIssue = None\n        self.setText(f\"Upgrade Node {self.nodeName}\")\n\n    def redoImpl(self):\n        if not (node := self.graph.node(self.nodeName)).canUpgrade:\n            return False\n        self.compatibilityIssue = node.issue\n        return self.graph.upgradeNode(self.nodeName)\n\n    def undoImpl(self):\n        expectedUid = None\n        if self.compatibilityIssue == CompatibilityIssue.UidConflict:\n            expectedUid = self.graph.node(self.nodeName)._uid\n\n        # recreate compatibility node\n        with GraphModification(self.graph):\n            node = nodeFactory(self.nodeDict, name=self.nodeName, expectedUid=expectedUid)\n            self.graph.replaceNode(self.nodeName, node)\n\n\nclass EnableGraphUpdateCommand(GraphCommand):\n    \"\"\" Command to enable/disable graph update.\n    Should not be used directly, use GroupedGraphModification context manager instead.\n    \"\"\"\n    def __init__(self, graph, enabled, parent=None):\n        super().__init__(graph, parent)\n        self.enabled = enabled\n        self.previousState = self.graph.updateEnabled\n\n    def redoImpl(self):\n        self.graph.updateEnabled = self.enabled\n        return True\n\n    def undoImpl(self):\n        self.graph.updateEnabled = self.previousState\n\n\n@contextmanager\ndef GroupedGraphModification(graph, undoStack, title, disableUpdates=True):\n    \"\"\" A context manager that creates a macro command disabling (if not already) graph update by default\n    and resetting its status after nested block execution.\n\n    Args:\n        graph (Graph): the Graph that will be modified\n        undoStack (UndoStack): the UndoStack to work with\n        title (str): the title of the macro command\n        disableUpdates (bool): whether to disable graph updates\n    \"\"\"\n    # Store graph update state\n    state = graph.updateEnabled\n    # Create a new command macro and push a command that disable graph updates\n    undoStack.beginMacro(title)\n    if disableUpdates:\n        undoStack.tryAndPush(EnableGraphUpdateCommand(graph, False))\n    try:\n        yield  # Execute nested block\n    except Exception:\n        raise\n    finally:\n        if disableUpdates:\n            # Push a command restoring graph update state and end command macro\n            undoStack.tryAndPush(EnableGraphUpdateCommand(graph, state))\n        undoStack.endMacro()\n"
  },
  {
    "path": "meshroom/ui/components/__init__.py",
    "content": "\ndef registerTypes():\n    from PySide6.QtQml import qmlRegisterType, qmlRegisterSingletonType\n    from meshroom.ui.components.clipboard import ClipboardHelper\n    from meshroom.ui.components.edge import EdgeMouseArea\n    from meshroom.ui.components.filepath import FilepathHelper\n    from meshroom.ui.components.scene3D import Scene3DHelper, TrackballController, Transformations3DHelper\n    from meshroom.ui.components.csvData import CsvData\n    from meshroom.ui.components.geom2D import Geom2D\n    from meshroom.ui.components.scriptEditor import PySyntaxHighlighter\n    from meshroom.ui.components.logLinesModel import LogLinesModel, LogLevelEnum\n\n    qmlRegisterType(EdgeMouseArea, \"GraphEditor\", 1, 0, \"EdgeMouseArea\")\n    qmlRegisterType(ClipboardHelper, \"Meshroom.Helpers\", 1, 0, \"ClipboardHelper\")  # TODO: uncreatable\n    qmlRegisterType(FilepathHelper, \"Meshroom.Helpers\", 1, 0, \"FilepathHelper\")  # TODO: uncreatable\n    qmlRegisterType(Scene3DHelper, \"Meshroom.Helpers\", 1, 0, \"Scene3DHelper\")  # TODO: uncreatable\n    qmlRegisterType(Transformations3DHelper, \"Meshroom.Helpers\", 1, 0, \"Transformations3DHelper\")  # TODO: uncreatable\n    qmlRegisterType(TrackballController, \"Meshroom.Helpers\", 1, 0, \"TrackballController\")\n    qmlRegisterType(CsvData, \"DataObjects\", 1, 0, \"CsvData\")\n    qmlRegisterType(LogLinesModel, \"DataObjects\", 1, 0, \"LogLinesModel\")\n    qmlRegisterType(PySyntaxHighlighter, \"ScriptEditor\", 1, 0, \"PySyntaxHighlighter\")\n\n    qmlRegisterSingletonType(Geom2D, \"Meshroom.Helpers\", 1, 0, \"Geom2D\")\n    qmlRegisterSingletonType(LogLevelEnum, \"DataObjects\", 1, 0, \"LogLevelEnum\")"
  },
  {
    "path": "meshroom/ui/components/clipboard.py",
    "content": "from PySide6.QtCore import Slot, QObject\nfrom PySide6.QtGui import QGuiApplication\n\n\nclass ClipboardHelper(QObject):\n    \"\"\"\n    Simple wrapper around a QClipboard with methods exposed as Slots for QML use.\n    \"\"\"\n\n    def __init__(self, parent=None):\n        super(ClipboardHelper, self).__init__(parent)\n        self._clipboard = QGuiApplication.clipboard()\n\n    @Slot(str)\n    def setText(self, value):\n        self._clipboard.setText(value)\n\n    @Slot(result=str)\n    def getText(self):\n        return self._clipboard.text()\n\n    @Slot()\n    def clear(self):\n        self._clipboard.clear()\n"
  },
  {
    "path": "meshroom/ui/components/csvData.py",
    "content": "from meshroom.common.qt import QObjectListModel\n\nfrom PySide6.QtCore import QObject, Slot, Signal, Property\nfrom PySide6 import QtCharts\n\nimport csv\nimport os\nimport logging\n\n\nclass CsvData(QObject):\n    \"\"\"Store data from a CSV file.\"\"\"\n    def __init__(self, parent=None):\n        \"\"\"Initialize the object without any parameter.\"\"\"\n        super(CsvData, self).__init__(parent=parent)\n        self._filepath = \"\"\n        self._data = QObjectListModel(parent=self)  # List of CsvColumn\n        self._ready = False\n        self.filepathChanged.connect(self.updateData)\n\n    @Slot(int, result=QObject)\n    def getColumn(self, index):\n        return self._data.at(index)\n\n    @Slot(result=str)\n    def getFilepath(self):\n        return self._filepath\n\n    @Slot(result=int)\n    def getNbColumns(self):\n        if self._ready:\n            return len(self._data)\n        else:\n            return 0\n\n    @Slot(str)\n    def setFilepath(self, filepath):\n        if self._filepath == filepath:\n            return\n        self.setReady(False)\n        self._filepath = filepath\n        self.filepathChanged.emit()\n\n    def setReady(self, ready):\n        if self._ready == ready:\n            return\n        self._ready = ready\n        self.readyChanged.emit()\n\n    @Slot()\n    def updateData(self):\n        self.setReady(False)\n        self._data.clear()\n        newColumns = self.read()\n        if newColumns:\n            self._data.setObjectList(newColumns)\n            self.setReady(True)\n\n    def read(self):\n        \"\"\"Read the CSV file and return a list containing CsvColumn objects.\"\"\"\n        if not self._filepath or not self._filepath.lower().endswith(\".csv\") or not os.path.isfile(self._filepath):\n            return []\n\n        dataList = []\n        try:\n            csvRows = []\n            with open(self._filepath, \"r\") as fp:\n                reader = csv.reader(fp)\n                for row in reader:\n                    csvRows.append(row)\n            # Create the objects in dataList\n            # with the first line elements as objects' title\n            for elt in csvRows[0]:\n                dataList.append(CsvColumn(elt)) # , parent=self._data\n            # Populate the content attribute\n            for elt in csvRows[1:]:\n                for idx, value in enumerate(elt):\n                    dataList[idx].appendValue(value)\n        except Exception as exc:\n            logging.error(f\"CsvData: Failed to load file: {self._filepath}\\n{exc}\")\n\n        return dataList\n\n    filepathChanged = Signal()\n    filepath = Property(str, getFilepath, setFilepath, notify=filepathChanged)\n    readyChanged = Signal()\n    ready = Property(bool, lambda self: self._ready, notify=readyChanged)\n    data = Property(QObject, lambda self: self._data, notify=readyChanged)\n    nbColumns = Property(int, getNbColumns, notify=readyChanged)\n\n\nclass CsvColumn(QObject):\n    \"\"\"Store content of a CSV column.\"\"\"\n    def __init__(self, title=\"\", parent=None):\n        \"\"\"Initialize the object with optional column title parameter.\"\"\"\n        super(CsvColumn, self).__init__(parent=parent)\n        self._title = title\n        self._content = []\n\n    def appendValue(self, value):\n        self._content.append(value)\n\n    @Slot(result=str)\n    def getFirst(self):\n        if not self._content:\n            return \"\"\n        return self._content[0]\n\n    @Slot(result=str)\n    def getLast(self):\n        if not self._content:\n            return \"\"\n        return self._content[-1]\n\n    @Slot(QtCharts.QXYSeries)\n    def fillChartSerie(self, serie):\n        \"\"\"Fill XYSerie used for displaying QML Chart.\"\"\"\n        if not serie:\n            return\n        serie.clear()\n        for index, value in enumerate(self._content):\n            serie.append(float(index), float(value))\n\n    title = Property(str, lambda self: self._title, constant=True)\n    content = Property(\"QStringList\", lambda self: self._content, constant=True)\n"
  },
  {
    "path": "meshroom/ui/components/edge.py",
    "content": "from PySide6.QtCore import Signal, Property, QPointF, Qt, QObject, Slot, QRectF\nfrom PySide6.QtGui import QPainterPath, QVector2D\nfrom PySide6.QtQuick import QQuickItem\n\n\nclass MouseEvent(QObject):\n    \"\"\"\n    Simple MouseEvent object, since QQuickMouseEvent is not accessible in the public API\n    \"\"\"\n    def __init__(self, evt):\n        super(MouseEvent, self).__init__()\n        self._x = evt.position().x()\n        self._y = evt.position().y()\n        self._button = evt.button()\n        self._modifiers = evt.modifiers()\n\n    x = Property(float, lambda self: self._x, constant=True)\n    y = Property(float, lambda self: self._y, constant=True)\n    button = Property(Qt.MouseButton, lambda self: self._button, constant=True)\n    modifiers = Property(Qt.KeyboardModifier, lambda self: self._modifiers, constant=True)\n\n\nclass EdgeMouseArea(QQuickItem):\n    \"\"\"\n    Provides a MouseArea shaped as a cubic spline for mouse interaction with edges.\n    Spline goes from (0,0) to (width, height). Works with negative values.\n    \"\"\"\n    def __init__(self, parent=None):\n        super(EdgeMouseArea, self).__init__(parent)\n\n        self._curveScale = 0.7\n        self._thickness = 2.0\n        self._containsMouse = False\n        self._path = None  # type: QPainterPath\n\n        self.setAcceptHoverEvents(True)\n        self.setAcceptedMouseButtons(Qt.AllButtons)\n\n    def contains(self, point):\n        return self._path.contains(point)\n\n    def hoverEnterEvent(self, evt):\n        self.setContainsMouse(True)\n        super(EdgeMouseArea, self).hoverEnterEvent(evt)\n\n    def hoverLeaveEvent(self, evt):\n        self.setContainsMouse(False)\n        super(EdgeMouseArea, self).hoverLeaveEvent(evt)\n\n    def geometryChange(self, newGeometry, oldGeometry):\n        super(EdgeMouseArea, self).geometryChange(newGeometry, oldGeometry)\n        self.updateShape()\n\n    def mousePressEvent(self, evt):\n        if not self.acceptedMouseButtons() & evt.button():\n            evt.setAccepted(False)\n            return\n        e = MouseEvent(evt)\n        self.pressed.emit(e)\n        e.deleteLater()\n\n    def mouseReleaseEvent(self, evt):\n        e = MouseEvent(evt)\n        self.released.emit(e)\n        e.deleteLater()\n\n    def updateShape(self):\n        p1 = QPointF(0, 0)\n        p2 = QPointF(self.width(), self.height())\n        ctrlPt = QPointF(abs(self.width() * self.curveScale), 0)\n        path = QPainterPath(p1)\n        path.cubicTo(p1 + ctrlPt, p2 - ctrlPt, p2)\n\n        # Compute offset on x and y axis\n        halfThickness = self._thickness / 2.0\n        v = QVector2D(p2 - p1).normalized()\n        offset = QPointF(halfThickness * -v.y(), halfThickness * v.x())\n\n        self._path = QPainterPath(path.toReversed())\n        self._path.translate(-offset)\n        path.translate(offset)\n        self._path.connectPath(path)\n\n    def getThickness(self):\n        return self._thickness\n\n    def setThickness(self, value):\n        if self._thickness == value:\n            return\n        self._thickness = value\n        self.thicknessChanged.emit()\n        self.updateShape()\n\n    def getCurveScale(self):\n        return self._curveScale\n\n    def setCurveScale(self, value):\n        if self.curveScale == value:\n            return\n        self._curveScale = value\n        self.curveScaleChanged.emit()\n        self.updateShape()\n\n    def getContainsMouse(self):\n        return self._containsMouse\n\n    def setContainsMouse(self, value):\n        if self._containsMouse == value:\n            return\n        self._containsMouse = value\n        self.containsMouseChanged.emit()\n\n    @Slot(QPointF, QPointF, result=bool)\n    def intersectsSegment(self, p1, p2):\n        \"\"\" Checks whether the given segment (p1, p2) intersects with the Path. \"\"\"\n        path = QPainterPath()\n        # Starting point\n        path.moveTo(p1)\n        # Create a diagonal line to the other end of the rect\n        path.lineTo(p2)\n        v = self._path.intersects(path)\n        return v\n\n    thicknessChanged = Signal()\n    thickness = Property(float, getThickness, setThickness, notify=thicknessChanged)\n    curveScaleChanged = Signal()\n    curveScale = Property(float, getCurveScale, setCurveScale, notify=curveScaleChanged)\n    containsMouseChanged = Signal()\n    containsMouse = Property(float, getContainsMouse, notify=containsMouseChanged)\n    acceptedButtons = Property(int,\n                               lambda self: super(EdgeMouseArea, self).acceptedMouseButtons,\n                               lambda self, value: super(EdgeMouseArea, self).setAcceptedMouseButtons(Qt.MouseButtons(value)))\n\n    pressed = Signal(MouseEvent)\n    released = Signal(MouseEvent)\n"
  },
  {
    "path": "meshroom/ui/components/filepath.py",
    "content": "#!/usr/bin/env python\n# coding:utf-8\nfrom PySide6.QtCore import QUrl, QFileInfo\nfrom PySide6.QtCore import QObject, Slot\n\nimport os\nimport glob\nimport pyseq\n\n\nclass FilepathHelper(QObject):\n    \"\"\"\n    FilepathHelper gives access to file path methods not available from JS.\n\n    It should be non-instantiable and expose only static methods, but this is not yet\n    possible in PySide.\n    \"\"\"\n\n    @staticmethod\n    def asStr(path):\n        \"\"\"\n        Accepts strings and QUrls and always returns 'path' as a string.\n\n        Args:\n            path (str or QUrl): the filepath to consider\n\n        Returns:\n            str: String representation of 'path'\n        \"\"\"\n        if not isinstance(path, (QUrl, str)):\n            raise TypeError(f\"Unexpected data type: {path.__class__}\")\n        if isinstance(path, QUrl):\n            path = path.toLocalFile()\n        return path\n\n    @Slot(str, result=str)\n    @Slot(QUrl, result=str)\n    def basename(self, path):\n        \"\"\" Returns the final component of a pathname. \"\"\"\n        return os.path.basename(self.asStr(path))\n\n    @Slot(str, result=str)\n    @Slot(QUrl, result=str)\n    def dirname(self, path):\n        \"\"\" Returns the directory component of a pathname. \"\"\"\n        return os.path.dirname(self.asStr(path))\n\n    @Slot(str, result=str)\n    @Slot(QUrl, result=str)\n    def extension(self, path):\n        \"\"\" Returns the extension (.ext) of a pathname. \"\"\"\n        return os.path.splitext(self.asStr(path))[-1]\n\n    @Slot(str, result=str)\n    @Slot(QUrl, result=str)\n    def removeExtension(self, path):\n        \"\"\" Returns the pathname without its extension (.ext). \"\"\"\n        return os.path.splitext(self.asStr(path))[0]\n\n    @Slot(str, result=bool)\n    @Slot(QUrl, result=bool)\n    def accessible(self, path):\n        \"\"\" Returns whether a path is accessible for the user \"\"\"\n        path = self.asStr(path)\n        return os.path.isdir(self.asStr(path)) and os.access(path, os.R_OK) and os.access(path, os.W_OK)\n\n    @Slot(str, result=bool)\n    @Slot(QUrl, result=bool)\n    def isFile(self, path):\n        \"\"\" Test whether a path is a regular file. \"\"\"\n        return os.path.isfile(self.asStr(path))\n\n    @Slot(str, result=bool)\n    @Slot(QUrl, result=bool)\n    def exists(self, path):\n        \"\"\" Test whether a path exists. \"\"\"\n        return os.path.exists(self.asStr(path))\n\n    @Slot(QUrl, result=str)\n    def urlToString(self, url):\n        \"\"\" Convert QUrl to a string using 'QUrl.toLocalFile' method. \"\"\"\n        return self.asStr(url)\n\n    @Slot(str, result=QUrl)\n    def stringToUrl(self, path):\n        \"\"\" Convert a path (string) to a QUrl using 'QUrl.fromLocalFile' method. \"\"\"\n        return QUrl.fromLocalFile(path)\n\n    @Slot(str, result=str)\n    @Slot(QUrl, result=str)\n    def normpath(self, path):\n        \"\"\" Returns native normalized path. \"\"\"\n        return os.path.normpath(self.asStr(path))\n\n    @Slot(str, result=str)\n    @Slot(QUrl, result=str)\n    def globFirst(self, path):\n        \"\"\" Returns the first from a list of paths matching a pathname pattern. \"\"\"\n        import glob\n        fileList = glob.glob(self.asStr(path))\n        fileList.sort()\n        if fileList:\n          return fileList[0]\n        return \"\"\n\n    @Slot(QUrl, result=int)\n    def fileSizeMB(self, path):\n        \"\"\" Returns the file size in MB. \"\"\"\n        return QFileInfo(self.asStr(path)).size() / (1024*1024)\n\n    @Slot(str, QObject, result=str)\n    def resolve(self, path, vp):\n        # Resolve dynamic path that depends on viewpoint\n        from meshroom.core import fileUtils\n\n        if vp == None:\n            replacements = FilepathHelper.getFilenamesFromFolder(FilepathHelper, FilepathHelper.dirname(FilepathHelper, path), FilepathHelper.extension(FilepathHelper, path))\n            resolved = [path for i in range(len(replacements))]\n            for key in replacements:\n                for i in range(len(resolved)):\n                    resolved[i] = resolved[i].replace(\"<FRAMEID>\", replacements[i])\n            return resolved\n\n        return fileUtils.resolvePath(vp, path)\n\n    @Slot(str, result=\"QVariantList\")\n    @Slot(str, str, result=\"QVariantList\")\n    def getFilenamesFromFolder(self, folderPath: str, extension: str = None):\n        \"\"\"\n        Get all filenames from a folder with a specific extension.\n\n        :param folderPath: Path to the folder.\n        :param extension: Extension of the files to get.\n        :return: List of filenames.\n        \"\"\"\n        if extension is None:\n            extension = \".*\"\n        return [self.basename(f) for f in glob.glob(os.path.join(folderPath, f\"*{extension}\")) if os.path.isfile(f)]\n\n    @Slot(str, bool, result=\"QVariantList\")\n    def resolveSequence(self, path, includesSeqMissingFiles):\n        \"\"\"\n        Get id of each file in the sequence.\n        \"\"\"\n        # use of pyseq to get the sequences\n        seqs = pyseq.get_sequences(self.asStr(path))\n\n        frameRanges = [[seq.start(), seq.end()] for seq in seqs]\n\n        # create the resolved path for each sequence\n        if includesSeqMissingFiles:\n            resolved = []\n            for seq in seqs:\n                if not seq.frames():\n                    # In case of a single frame, pyseq does not exctract a frameNumber\n                    s = [fileItem.path for fileItem in seq]\n                else:\n                    # Create all frames between start and end, even for missing files\n                    s = [seq.format(\"%D%h%p%t\") % frameNumber for frameNumber in range(seq.start(), seq.end() + 1)]\n                resolved.append(s)\n        else:\n            resolved = [[fileItem.path for fileItem in seq] for seq in seqs]\n        return frameRanges, resolved\n"
  },
  {
    "path": "meshroom/ui/components/geom2D.py",
    "content": "from PySide6.QtCore import QObject, Slot, QRectF\n\n\nclass Geom2D(QObject):\n    @Slot(QRectF, QRectF, result=bool)\n    def rectRectIntersect(self, rect1: QRectF, rect2: QRectF) -> bool:\n        \"\"\" Check if two rectangles intersect. \"\"\"\n        return rect1.intersects(rect2)\n\n    @Slot(QRectF, QRectF, result=bool)\n    def rectRectFullIntersect(self, rect1: QRectF, rect2: QRectF) -> bool:\n        \"\"\" Check if two rectangles intersect fully. i.e. rect1 fully holds rect2 inside it.\"\"\"\n        intersected = rect1.intersected(rect2)\n\n        # They do not intersect at all\n        if not intersected:\n            return False\n\n        # Validate that intersected rect is same as rect2\n        # If both are same, that implies it fully lies inside of rect1\n        return intersected == rect2\n"
  },
  {
    "path": "meshroom/ui/components/logLinesModel.py",
    "content": "from PySide6.QtCore import QAbstractListModel, Qt, QModelIndex, Slot, QObject, Property\n\nimport re\nfrom enum import IntEnum\n\n\nclass LogLevel(IntEnum):\n    \"\"\"\n    Enum for log levels.\n    \n    These values can be used in QML for filtering, styling, or conditional logic.\n    \"\"\"\n    UNKNOWN = 0\n    TRACE = 1\n    DEBUG = 2\n    INFO = 3\n    WARNING = 4\n    ERROR = 5\n    CRITICAL = 6\n    FATAL = 7\n\n\nclass LogLevelEnum(QObject):\n    \"\"\"\n    Wrapper class to expose LogLevel enum to QML.\n    \n    Usage in QML:\n        import DataObjects 1.0\n        \n        if (level === LogLevelEnum.ERROR) {\n            // Handle error\n        }\n    \"\"\"\n    \n    def __init__(self, parent=None):\n        super().__init__(parent)\n\n    @Property(int, constant=True)\n    def UNKNOWN(self):\n        return int(LogLevel.UNKNOWN)\n    \n    @Property(int, constant=True)\n    def TRACE(self):\n        return int(LogLevel.TRACE)\n    \n    @Property(int, constant=True)\n    def DEBUG(self):\n        return int(LogLevel.DEBUG)\n    \n    @Property(int, constant=True)\n    def INFO(self):\n        return int(LogLevel.INFO)\n    \n    @Property(int, constant=True)\n    def WARNING(self):\n        return int(LogLevel.WARNING)\n    \n    @Property(int, constant=True)\n    def ERROR(self):\n        return int(LogLevel.ERROR)\n    \n    @Property(int, constant=True)\n    def CRITICAL(self):\n        return int(LogLevel.CRITICAL)\n    \n    @Property(int, constant=True)\n    def FATAL(self):\n        return int(LogLevel.FATAL)\n\nclass LogLinesModel(QAbstractListModel):\n    \"\"\"\n    Model for log lines with duration tracking.\n    \n    This Qt model parses log text and extracts metadata including timestamps,\n    log levels, and calculates the duration (in seconds) between consecutive\n    timestamped log entries.\n    \n    Expected log format:\n        [HH:MM:SS][LEVEL][optional:numbers] message text\n        Example: [12:34:56][INFO][1:23] Application started\n    \n    Each item in the model contains:\n        - line: The message text (without metadata)\n        - time: The timestamp string (HH:MM:SS)\n        - level: The log level as an enum (LogLevel)\n        - duration: Seconds elapsed since the previous timestamped line (-1 if not applicable)\n    \n    Attributes:\n        LineRole: Custom role for accessing the message text\n        LevelRole: Custom role for accessing the log level (as LogLevel enum)\n        TimeRole: Custom role for accessing the timestamp\n        DurationRole: Custom role for accessing the duration between log entries\n    \"\"\"\n    \n    # Custom roles for data access\n    LineRole = Qt.UserRole + 1      # Message text\n    LevelRole = Qt.UserRole + 2     # Log level (LogLevel enum)\n    TimeRole = Qt.UserRole + 3      # Timestamp (HH:MM:SS)\n    DurationRole = Qt.UserRole + 4  # Duration in seconds since previous timestamped line\n    \n    # Mapping from string log levels to enum values\n    _LEVEL_MAP = {\n        'trace': LogLevel.TRACE,\n        'debug': LogLevel.DEBUG,\n        'info': LogLevel.INFO,\n        'warning': LogLevel.WARNING,\n        'warn': LogLevel.WARNING,\n        'error': LogLevel.ERROR,\n        'critical': LogLevel.CRITICAL,\n        'fatal': LogLevel.FATAL,\n    }\n    \n    def __init__(self, parent=None):\n        \"\"\"\n        Initialize the LogLinesModel.\n        \n        Args:\n            parent: Optional parent QObject\n        \"\"\"\n        super().__init__(parent)\n        self._lines = []  # List of dictionaries containing parsed log data\n        \n        # Regex pattern to parse log format: [timestamp][level][optional:numbers] message\n        # Groups: 1=time, 2=hours, 3=minutes, 4=seconds, 5=level, 6=optional1, 7=optional2, 8=message\n        self._format_regex = re.compile(r'^\\[[^]]*?((\\d{2}):(\\d{2}):(\\d{2}))[^]]*\\]\\[([A-Za-z]+)\\](?:\\[(\\d+):(\\d+)\\])?\\s*(.*)$')\n    \n    def rowCount(self, parent=QModelIndex()):\n        \"\"\"\n        Return the number of rows in the model.\n        \n        Args:\n            parent: Parent index (unused, as this is a flat list model)\n            \n        Returns:\n            int: Number of log lines in the model\n        \"\"\"\n        if parent.isValid():\n            return 0\n        return len(self._lines)\n    \n    def data(self, index, role=Qt.DisplayRole):\n        \"\"\"\n        Retrieve data for a given index and role.\n        \n        Args:\n            index: QModelIndex for the requested item\n            role: The data role being requested\n            \n        Returns:\n            The requested data, or None if invalid index or role\n        \"\"\"\n        if not index.isValid() or index.row() >= len(self._lines):\n            return None\n        \n        item = self._lines[index.row()]\n        \n        if role == self.LineRole or role == Qt.DisplayRole:\n            return item[\"line\"]\n        elif role == self.LevelRole:\n            return item[\"level\"]  # Returns LogLevel enum value (int)\n        elif role == self.TimeRole:\n            return item[\"time\"]\n        elif role == self.DurationRole:\n            return item[\"duration\"]\n        \n        return None\n    \n    def roleNames(self):\n        \"\"\"\n        Define role names for QML access.\n        \n        Returns:\n            dict: Mapping of role IDs to byte-encoded role names\n        \"\"\"\n        return {\n            self.LineRole: b\"line\",\n            self.LevelRole: b\"level\",\n            self.TimeRole: b\"time\",\n            self.DurationRole: b\"duration\"\n        }\n    \n    @Slot(str)\n    def setText(self, text):\n        \"\"\"\n        Parse log text and update the model with lines and durations.\n        \n        This method:\n        1. Splits the input text into lines\n        2. Parses each line to extract metadata (time, level, message)\n        3. Calculates duration between consecutive timestamped lines\n        4. Updates the model with the parsed data\n        \n        Args:\n            text: Multi-line string containing log entries\n        \"\"\"\n        self.beginResetModel()\n        \n        self._lines = []\n        if not text:\n            self.endResetModel()\n            return\n        \n        # Split text into individual lines\n        lines = text.split('\\n')\n        \n        \n        # Calculate durations between consecutive timestamped lines\n        prev_seconds = -1\n        for line in lines:\n            delta = -1\n            \n            metadata = self.parseMetadata(line)\n            \n            seconds = metadata[\"seconds\"]\n            if seconds >= 0:\n                if prev_seconds >= 0:\n                    delta = seconds - prev_seconds\n                prev_seconds = seconds\n            \n            self._lines.append({\n                \"line\": metadata[\"line\"],\n                \"time\": metadata[\"time\"],\n                \"level\": int(metadata[\"level\"]),\n                \"duration\": delta\n            })\n        \n        self.endResetModel()\n    \n    def parseMetadata(self, line):\n        \"\"\"\n        Parse a single log line to extract metadata.\n        \n        Expected format: [HH:MM:SS][LEVEL][optional:numbers] message\n        \n        Args:\n            line: A single line of log text\n            \n        Returns:\n            dict: Parsed metadata with keys:\n                - line (str): The message text\n                - time (str): Timestamp in HH:MM:SS format\n                - seconds (int): Total seconds since midnight (for duration calculation)\n                - level (LogLevel): Log level as enum value\n        \"\"\"\n        text = line\n        time = \"00:00:00\"\n        level = LogLevel.INFO\n        seconds = -1\n        \n        match = self._format_regex.match(line)\n        if match:\n            # Extract matched groups\n            time = match.group(1)      # HH:MM:SS\n            level_str = match.group(5).lower()  # Log level string\n            text = match.group(8)      # Message text\n            \n            # Convert string level to enum\n            level = self._LEVEL_MAP.get(level_str, LogLevel.UNKNOWN)\n\n            # Convert time to total seconds for duration calculation\n            try:\n                hh = int(match.group(2))  # Hours\n                mm = int(match.group(3))  # Minutes\n                ss = int(match.group(4))  # Seconds\n                seconds = ss + 60 * mm + 3600 * hh\n            except ValueError:\n                # If conversion fails, keep seconds at -1 (Sentinel value)\n                pass\n        \n        return {\n            \"line\": text,\n            \"time\": time,\n            \"seconds\": seconds,\n            \"level\": level\n        }\n    \n    @Slot(result=int)\n    def count(self):\n        \"\"\"\n        Return the number of lines in the model.\n        \n        This is a convenience method for QML compatibility.\n        \n        Returns:\n            int: Number of log lines\n        \"\"\"\n        return len(self._lines)\n    \n    @Slot(int, result='QVariant')\n    def get(self, index):\n        \"\"\"\n        Get the item at the specified index.\n        \n        This method provides QML-style access similar to ListModel.get().\n        \n        Args:\n            index: The index of the item to retrieve\n            \n        Returns:\n            dict: The item data if index is valid, None otherwise\n        \"\"\"\n        if 0 <= index < len(self._lines):\n            return self._lines[index]\n        return None\n    \n    @Slot()\n    def clear(self):\n        \"\"\"\n        Clear all lines from the model.\n        \n        This removes all log entries and resets the model to an empty state.\n        \"\"\"\n        self.beginResetModel()\n        self._lines = []\n        self.endResetModel()\n    \n    @Slot(int, result=str)\n    def levelToString(self, level):\n        \"\"\"\n        Convert a LogLevel enum value to its string representation.\n        \n        Useful in QML for displaying log level names.\n        \n        Args:\n            level: LogLevel enum value\n            \n        Returns:\n            str: String representation of the log level\n        \"\"\"\n        try:\n            return LogLevel(level).name\n        except ValueError:\n            return \"UNKNOWN\""
  },
  {
    "path": "meshroom/ui/components/messaging.py",
    "content": "import json\nfrom PySide6.QtCore import QObject\nfrom datetime import datetime\nfrom meshroom.common import Signal, Slot, Property\n\n\nclass Message:\n    def __init__(self, msg, status=None):\n        self.msg = msg\n        self.status = status or \"info\"\n        self.date = datetime.now()\n\n    def dateStr(self, fullDate=False):\n        dateFormat = \"%H:%M:%S\"\n        if fullDate:\n            dateFormat = \"%Y-%m-%d %H:%M:%S.%f\"\n        return self.date.strftime(dateFormat)\n\n\nclass MessageController(QObject):\n    \"\"\"\n    Handles messages sent from the Python side to the StatusBar component.\n    \"\"\"\n\n    message = Signal(str, str, int)\n    messagesChanged = Signal()  # Signal to notify when messages list changes\n\n    def __init__(self, parent):\n        super().__init__(parent)\n        self._messages = []\n\n    def sendMessage(self, msg, status, duration):\n        \"\"\" Sends a message that will be displayed on the status bar. \"\"\"\n        self.message.emit(msg, status, duration)\n\n    @Slot(str, str)\n    def storeMessage(self, msg, status):\n        \"\"\" Adds a new message in the stack. \"\"\"\n        self._messages.append(Message(msg, status or \"info\"))\n        self.messagesChanged.emit()  # Notify QML that messages have changed\n\n    def _getMessagesDict(self, fullDate=False):\n        \"\"\" Get a dict with all stored messages. \"\"\"\n        messages = []\n        for msg in self._messages:\n            messages.append({\n                \"status\": msg.status,\n                \"date\": msg.dateStr(fullDate),\n                \"text\": msg.msg,\n            })\n        return messages\n\n    def getMessages(self):\n        \"\"\"\n        Get the messages with simple date information.\n        Reverse the list to make sure we see the most recent item on top\n        \"\"\"\n        return self._getMessagesDict()[::-1]\n\n    @Slot(result=str)\n    def getMessagesAsString(self):\n        \"\"\"\n        Return messages for clipboard copy.\n        .. note::\n           Could also do `json.dumps(self._getMessagesDict(fullDate=True), indent=4)`\n        \"\"\"\n        messages = []\n        for msg in self._messages:\n            messages.append(f\"{msg.dateStr(True)} [{msg.status.upper():<7}] {msg.msg}\")\n        return \"\\n\".join(messages)\n\n    @Slot()\n    def clearMessages(self):\n        \"\"\" Clear all stored messages. \"\"\"\n        self._messages.clear()\n        self.messagesChanged.emit()\n\n    # Property to expose messages to QML\n    messages = Property(\"QVariantList\", getMessages, notify=messagesChanged)\n"
  },
  {
    "path": "meshroom/ui/components/scene3D.py",
    "content": "from math import acos, pi, sqrt, atan2, cos, sin, asin\n\nfrom PySide6.QtCore import QObject, Slot, QSize, Signal, QPointF\nfrom PySide6.Qt3DCore import Qt3DCore\nfrom PySide6.Qt3DRender import Qt3DRender\nfrom PySide6.QtGui import QVector3D, QQuaternion, QVector2D, QVector4D, QMatrix4x4\n\nfrom meshroom.ui.utils import makeProperty\n\nclass Scene3DHelper(QObject):\n\n    @Slot(Qt3DCore.QEntity, str, result=\"QVariantList\")\n    def findChildrenByProperty(self, entity, propertyName):\n        \"\"\" Recursively get all children of an entity that have a property named 'propertyName'. \"\"\"\n        children = []\n        for child in entity.childNodes():\n            try:\n                if child.metaObject().indexOfProperty(propertyName) != -1:\n                    children.append(child)\n            except RuntimeError:\n                continue\n            children += self.findChildrenByProperty(child, propertyName)\n        return children\n\n    @Slot(Qt3DCore.QEntity, Qt3DCore.QComponent)\n    def addComponent(self, entity, component):\n        \"\"\" Adds a component to an entity. \"\"\"\n        entity.addComponent(component)\n\n    @Slot(Qt3DCore.QEntity, Qt3DCore.QComponent)\n    def removeComponent(self, entity, component):\n        \"\"\" Removes a component from an entity. \"\"\"\n        entity.removeComponent(component)\n\n    @Slot(Qt3DCore.QEntity, result=int)\n    def vertexCount(self, entity):\n        \"\"\" Return vertex count based on children QGeometryRenderer 'vertexCount'. \"\"\"\n        return sum([renderer.vertexCount() for renderer in entity.findChildren(Qt3DRender.QGeometryRenderer)])\n\n    @Slot(Qt3DCore.QEntity, result=int)\n    def faceCount(self, entity):\n        \"\"\" Returns face count based on children QGeometry buffers size. \"\"\"\n        count = 0\n        for geo in entity.findChildren(Qt3DCore.QGeometry):\n            count += sum([attr.count() for attr in geo.attributes() if attr.name() == \"vertexPosition\"])\n        return count / 3\n\n    @Slot(Qt3DCore.QEntity, result=int)\n    def vertexColorCount(self, entity):\n        count = 0\n        for geo in entity.findChildren(Qt3DCore.QGeometry):\n            count += sum([attr.count() for attr in geo.attributes() if attr.name() == \"vertexColor\"])\n        return count\n\n\nclass TrackballController(QObject):\n    \"\"\"\n    Trackball-like camera controller.\n    Based on the C++ version from https://github.com/cjmdaixi/Qt3DTrackball\n    \"\"\"\n\n    def __init__(self, parent=None):\n        super().__init__(parent)\n        self._windowSize = QSize()\n        self._camera = None\n        self._trackballSize = 1.0\n        self._rotationSpeed = 5.0\n\n    def projectToTrackball(self, screenCoords):\n        sx = screenCoords.x()\n        sy = self._windowSize.height() - screenCoords.y()\n        p2d = QVector2D(sx / self._windowSize.width() - 0.5, sy / self._windowSize.height() - 0.5)\n        z = 0.0\n        r2 = pow(self._trackballSize, 2)\n        lengthSquared = p2d.lengthSquared()\n        if lengthSquared <= r2 * 0.5:\n            z = sqrt(r2 - lengthSquared)\n        else:\n            z = r2 * 0.5 / p2d.length()\n        return QVector3D(p2d.x(), p2d.y(), z)\n\n    @staticmethod\n    def clamp(x):\n        return max(-1, min(x, 1))\n\n    def createRotation(self, firstPoint, nextPoint):\n        lastPos3D = self.projectToTrackball(firstPoint).normalized()\n        currentPos3D = self.projectToTrackball(nextPoint).normalized()\n        angle = acos(self.clamp(QVector3D.dotProduct(currentPos3D, lastPos3D)))\n        direction = QVector3D.crossProduct(currentPos3D, lastPos3D)\n        return angle, direction\n\n    @Slot(QPointF, QPointF, float)\n    def rotate(self, lastPosition, currentPosition, dt):\n        angle, direction = self.createRotation(lastPosition, currentPosition)\n        rotatedAxis = self._camera.transform().rotation().rotatedVector(direction)\n        angle *= self._rotationSpeed * dt\n        self._camera.rotateAboutViewCenter(QQuaternion.fromAxisAndAngle(rotatedAxis, angle * pi * 180))\n\n    windowSizeChanged = Signal()\n    windowSize = makeProperty(QSize, '_windowSize', windowSizeChanged)\n    cameraChanged = Signal()\n    camera = makeProperty(QObject, '_camera', cameraChanged)\n    trackballSizeChanged = Signal()\n    trackballSize = makeProperty(float, '_trackballSize', trackballSizeChanged)\n    rotationSpeedChanged = Signal()\n    rotationSpeed = makeProperty(float, '_rotationSpeed', rotationSpeedChanged)\n\n\nclass Transformations3DHelper(QObject):\n\n    # ---------- Exposed to QML ---------- #\n\n    @Slot(QVector3D, QVector3D, result=QQuaternion)\n    def rotationBetweenAandB(self, A, B):\n        A = A/A.length()\n        B = B/B.length()\n\n        # Get rotation matrix between 2 vectors\n        v = QVector3D.crossProduct(A, B)\n        s = v.length()\n        c = QVector3D.dotProduct(A, B)\n        return QQuaternion.fromAxisAndAngle(v / s, atan2(s, c) * 180 / pi)\n\n    @Slot(QVector3D, result=QVector3D)\n    def fromEquirectangular(self, vector):\n        return QVector3D(cos(vector.x()) * sin(vector.y()), sin(vector.x()), cos(vector.x()) * cos(vector.y()))\n\n    @Slot(QVector3D, result=QVector3D)\n    def toEquirectangular(self, vector):\n        return QVector3D(asin(vector.y()), atan2(vector.x(), vector.z()), 0)\n\n    @Slot(QVector3D, QVector2D, QVector2D, result=QVector3D)\n    def updatePanorama(self, euler, ptStart, ptEnd):\n        delta = 1e-3\n\n        # Get initial rotation\n        qStart = QQuaternion.fromEulerAngles(euler.y(), euler.x(), euler.z())\n\n        # Convert input to points on unit sphere\n        vStart = self.fromEquirectangular(QVector3D(ptStart))\n        vStartdY = self.fromEquirectangular(QVector3D(ptStart.x(), ptStart.y() + delta, 0))\n        vEnd = self.fromEquirectangular(QVector3D(ptEnd))\n\n        qAdd = QQuaternion.rotationTo(vStart, vEnd)\n\n        # Get the 3D point on unit sphere which would correspond to the no rotation +X\n        vCurrent = qAdd.rotatedVector(vStartdY)\n        vIdeal = self.fromEquirectangular(QVector3D(ptEnd.x(), ptEnd.y() + delta, 0))\n\n        # Project on rotation plane\n        lambdaEnd = 1 / QVector3D.dotProduct(vEnd, vCurrent)\n        lambdaIdeal = 1 / QVector3D.dotProduct(vEnd, vIdeal)\n        vPlaneCurrent = lambdaEnd * vCurrent\n        vPlaneIdeal = lambdaIdeal * vIdeal\n\n        # Get the directions\n        rotStart = (vPlaneCurrent - vEnd).normalized()\n        rotEnd = (vPlaneIdeal - vEnd).normalized()\n\n        # Get rotation matrix between 2 vectors\n        v = QVector3D.crossProduct(rotEnd, rotStart)\n        s = QVector3D.dotProduct(v, vEnd)\n        c = QVector3D.dotProduct(rotStart, rotEnd)\n        angle = atan2(s, c) * 180 / pi\n\n        qImage = QQuaternion.fromAxisAndAngle(vEnd, -angle)\n\n        return (qImage * qAdd * qStart).toEulerAngles()\n\n    @Slot(QVector3D, QVector2D, QVector2D, result=QVector3D)\n    def updatePanoramaInPlane(self, euler, ptStart, ptEnd):\n        delta = 1e-3\n\n        # Get initial rotation\n        qStart = QQuaternion.fromEulerAngles(euler.y(), euler.x(), euler.z())\n\n        # Convert input to points on unit sphere\n        vStart = self.fromEquirectangular(QVector3D(ptStart))\n        vEnd = self.fromEquirectangular(QVector3D(ptEnd))\n\n        # Get the 3D point on unit sphere which would correspond to the no rotation +X\n        vIdeal = self.fromEquirectangular(QVector3D(ptStart.x(), ptStart.y() + delta, 0))\n\n        # Project on rotation plane\n        lambdaEnd = 1 / QVector3D.dotProduct(vStart, vEnd)\n        lambdaIdeal = 1 / QVector3D.dotProduct(vStart, vIdeal)\n        vPlaneEnd = lambdaEnd * vEnd\n        vPlaneIdeal = lambdaIdeal * vIdeal\n\n        # Get the directions\n        rotStart = (vPlaneEnd - vStart).normalized()\n        rotEnd = (vPlaneIdeal - vStart).normalized()\n\n        # Get rotation matrix between 2 vectors\n        v = QVector3D.crossProduct(rotEnd, rotStart)\n        s = QVector3D.dotProduct(v, vStart)\n        c = QVector3D.dotProduct(rotStart, rotEnd)\n        angle = atan2(s, c) * 180 / pi\n\n        qAdd = QQuaternion.fromAxisAndAngle(vStart, angle)\n\n        return (qAdd * qStart).toEulerAngles()\n\n    @Slot(QVector4D, Qt3DRender.QCamera, QSize, result=QVector2D)\n    def pointFromWorldToScreen(self, point, camera, windowSize):\n        \"\"\"\n        Compute the Screen point corresponding to a World Point.\n        Args:\n            point (QVector4D): point in world coordinates\n            camera (QCamera): camera viewing the scene\n            windowSize (QSize): size of the Scene3D window\n        Returns:\n            QVector2D: point in screen coordinates\n        \"\"\"\n        # Transform the point from World Coord to Normalized Device Coord\n        viewMatrix = camera.transform().matrix().inverted()\n        projectedPoint = (camera.projectionMatrix() * viewMatrix[0]).map(point)\n        projectedPoint2D = QVector2D(\n            projectedPoint.x()/projectedPoint.w(),\n            projectedPoint.y()/projectedPoint.w()\n        )\n\n        # Transform the point from Normalized Device Coord to Screen Coord\n        screenPoint2D = QVector2D(\n            int((projectedPoint2D.x() + 1) * windowSize.width() / 2),\n            int((projectedPoint2D.y() - 1) * windowSize.height() / -2)\n        )\n\n        return screenPoint2D\n\n    @Slot(Qt3DCore.QTransform, QMatrix4x4, QMatrix4x4, QMatrix4x4, QVector3D)\n    def relativeLocalTranslate(self, transformQtInstance, initialPosMat, initialRotMat, initialScaleMat, translateVec):\n        \"\"\"\n        Translate the QTransform in its local space relatively to an initial state.\n        Args:\n            transformQtInstance (QTransform): reference to the Transform to modify\n            initialPosMat (QMatrix4x4): initial position matrix\n            initialRotMat (QMatrix4x4): initial rotation matrix\n            initialScaleMat (QMatrix4x4): initial scale matrix\n            translateVec (QVector3D): vector used for the local translation\n        \"\"\"\n        # Compute the translation transformation matrix\n        translationMat = QMatrix4x4()\n        translationMat.translate(translateVec)\n\n        # Compute the new model matrix (POSITION * ROTATION * TRANSLATE * SCALE) and set it to the Transform\n        mat = initialPosMat * initialRotMat * translationMat * initialScaleMat\n        transformQtInstance.setMatrix(mat)\n\n    @Slot(Qt3DCore.QTransform, QMatrix4x4, QQuaternion, QMatrix4x4, QVector3D, int)\n    def relativeLocalRotate(self, transformQtInstance, initialPosMat, initialRotQuat, initialScaleMat, axis, degree):\n        \"\"\"\n        Rotate the QTransform in its local space relatively to an initial state.\n        Args:\n            transformQtInstance (QTransform): reference to the Transform to modify\n            initialPosMat (QMatrix4x4): initial position matrix\n            initialRotQuat (QQuaternion): initial rotation quaternion\n            initialScaleMat (QMatrix4x4): initial scale matrix\n            axis (QVector3D): axis to rotate around\n            degree (int): angle of rotation in degree\n        \"\"\"\n        # Compute the transformation quaternion from axis and angle in degrees\n        transformQuat = QQuaternion.fromAxisAndAngle(axis, degree)\n\n        # Compute the new rotation quaternion and then calculate the matrix\n        newRotQuat = initialRotQuat * transformQuat # Order is important\n        newRotationMat = self.quaternionToRotationMatrix(newRotQuat)\n\n        # Compute the new model matrix (POSITION * NEW_COMPUTED_ROTATION * SCALE) and set it to the Transform\n        mat = initialPosMat * newRotationMat * initialScaleMat\n        transformQtInstance.setMatrix(mat)\n\n    @Slot(Qt3DCore.QTransform, QMatrix4x4, QMatrix4x4, QMatrix4x4, QVector3D)\n    def relativeLocalScale(self, transformQtInstance, initialPosMat, initialRotMat, initialScaleMat, scaleVec):\n        \"\"\" Scale the QTransform in its local space relatively to an initial state.\n            Args:\n                transformQtInstance (QTransform): reference to the Transform to modify\n                initialPosMat (QMatrix4x4): initial position matrix\n                initialRotMat (QMatrix4x4): initial rotation matrix\n                initialScaleMat (QMatrix4x4): initial scale matrix\n                scaleVec (QVector3D): vector used for the relative scale\n        \"\"\"\n        # Make a copy of the scale matrix (otherwise, it is a reference and it does not work as expected)\n        scaleMat = self.copyMatrix4x4(initialScaleMat)\n\n        # Update the scale matrix copy (X then Y then Z) with the scaleVec values\n        scaleVecTuple = scaleVec.toTuple()\n        for i in range(3):\n            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\n            value = currentRow[i] + scaleVecTuple[i]\n            value = value if value >= 0 else -value # Make sure to have only positive scale (because negative scale can make issues with matrix decomposition)\n            currentRow[i] = value\n\n            scaleMat.setRow(i, QVector4D(currentRow[0], currentRow[1], currentRow[2], currentRow[3])) # Apply the new row to the scale matrix\n\n        # Compute the new model matrix (POSITION * ROTATION * SCALE) and set it to the Transform\n        mat = initialPosMat * initialRotMat * scaleMat\n        transformQtInstance.setMatrix(mat)\n\n    @Slot(QMatrix4x4, result=\"QVariant\")\n    def modelMatrixToMatrices(self, modelMat):\n        \"\"\"\n        Decompose a model matrix into individual matrices.\n        Args:\n            modelMat (QMatrix4x4): model matrix to decompose\n        Returns:\n            QVariant: object containing position, rotation and scale matrices + rotation quaternion\n        \"\"\"\n        decomposition = self.decomposeModelMatrix(modelMat)\n\n        posMat = QMatrix4x4()\n        posMat.translate(decomposition.get(\"translation\"))\n\n        rotMat = self.quaternionToRotationMatrix(decomposition.get(\"quaternion\"))\n\n        scaleMat = QMatrix4x4()\n        scaleMat.scale(decomposition.get(\"scale\"))\n\n        return {\"position\": posMat, \"rotation\": rotMat, \"scale\": scaleMat, \"quaternion\": decomposition.get(\"quaternion\")}\n\n    @Slot(QVector3D, QVector3D, QVector3D, result=QMatrix4x4)\n    def computeModelMatrixWithEuler(self, translation, rotation, scale):\n        \"\"\"\n        Compute a model matrix from three Vector3D.\n        Args:\n            translation (QVector3D): position in space (x, y, z)\n            rotation (QVector3D): Euler angles in degrees (x, y, z)\n            scale (QVector3D): scale of the object (x, y, z)\n        Returns:\n            QMatrix4x4: corresponding model matrix\n        \"\"\"\n        posMat = QMatrix4x4()\n        posMat.translate(translation)\n\n        quaternion = QQuaternion.fromEulerAngles(rotation)\n        rotMat = self.quaternionToRotationMatrix(quaternion)\n\n        scaleMat = QMatrix4x4()\n        scaleMat.scale(scale)\n\n        modelMat = posMat * rotMat * scaleMat\n\n        return modelMat\n\n    @Slot(QVector3D, result=QVector3D)\n    def convertRotationFromCV2GL(self, rotation):\n        \"\"\"\n        Convert rotation (euler angles) from Computer Vision to Computer Graphics coordinate system (like OpenGL).\n        \"\"\"\n        M = QQuaternion.fromAxisAndAngle(QVector3D(1, 0, 0), 180.0)\n\n        quaternion = QQuaternion.fromEulerAngles(rotation)\n\n        U = M * quaternion * M\n\n        return U.toEulerAngles()\n\n    @Slot(QVector3D, QVector3D, float, float, result=QVector3D)\n    def getRotatedCameraViewVector(self, camereViewVector, cameraUpVector, pitch, yaw):\n        \"\"\"\n        Compute the rotated camera view vector with given pitch and yaw (in degrees).\n        Args:\n            camereViewVector (QVector3D): Camera view vector, the displacement from the camera position to its target\n            cameraUpVector (QVector3D): Camera up vector, the direction the top of the camera is facing\n            pitch (float): Rotation pitch (in degrees)\n            yaw (float): Rotation yaw (in degrees)\n        Returns:\n            QVector3D: rotated camera view vector\n        \"\"\"\n        cameraSideVector = QVector3D.crossProduct(camereViewVector, cameraUpVector)\n        yawRot = QQuaternion.fromAxisAndAngle(cameraUpVector, yaw)\n        pitchRot = QQuaternion.fromAxisAndAngle(cameraSideVector, pitch)\n        return (yawRot * pitchRot).rotatedVector(camereViewVector)\n\n    @Slot(QVector3D, QMatrix4x4, Qt3DRender.QCamera, QSize, result=float)\n    def computeScaleUnitFromModelMatrix(self, axis, modelMat, camera, windowSize):\n        \"\"\"\n        Compute the length of the screen projected vector axis unit transformed by the model matrix.\n        Args:\n            axis (QVector3D): chosen axis ((1,0,0) or (0,1,0) or (0,0,1))\n            modelMat (QMatrix4x4): model matrix used for the transformation\n            camera (QCamera): camera viewing the scene\n            windowSize (QSize): size of the window in pixels\n        Returns:\n            float: length (in pixels)\n        \"\"\"\n        decomposition = self.decomposeModelMatrix(modelMat)\n\n        posMat = QMatrix4x4()\n        posMat.translate(decomposition.get(\"translation\"))\n\n        rotMat = self.quaternionToRotationMatrix(decomposition.get(\"quaternion\"))\n\n        unitScaleModelMat = posMat * rotMat * QMatrix4x4()\n\n        worldCenterPoint = unitScaleModelMat.map(QVector4D(0,0,0,1))\n        worldAxisUnitPoint = unitScaleModelMat.map(QVector4D(axis.x(),axis.y(),axis.z(),1))\n        screenCenter2D = self.pointFromWorldToScreen(worldCenterPoint, camera, windowSize)\n        screenAxisUnitPoint2D = self.pointFromWorldToScreen(worldAxisUnitPoint, camera, windowSize)\n\n        screenVector = QVector2D(screenAxisUnitPoint2D.x() - screenCenter2D.x(), -(screenAxisUnitPoint2D.y() - screenCenter2D.y()))\n\n        value = screenVector.length()\n        return value if (value and value > 10) else 10  # Threshold to avoid problems in extreme case\n\n    # ---------- \"Private\" Methods ---------- #\n\n    def copyMatrix4x4(self, mat):\n        \"\"\" Make a deep copy of a QMatrix4x4. \"\"\"\n        newMat = QMatrix4x4()\n        for i in range(4):\n            newMat.setRow(i, mat.row(i))\n        return newMat\n\n    def decomposeModelMatrix(self, modelMat):\n        \"\"\"\n        Decompose a model matrix into individual component.\n        Args:\n            modelMat (QMatrix4x4): model matrix to decompose\n        Returns:\n            QVariant: object containing translation and scale vectors + rotation quaternion\n        \"\"\"\n        translation = modelMat.column(3).toVector3D()\n        quaternion = QQuaternion.fromDirection(modelMat.column(2).toVector3D(), modelMat.column(1).toVector3D())\n        scale = QVector3D(modelMat.column(0).length(), modelMat.column(1).length(), modelMat.column(2).length())\n\n        return {\"translation\": translation, \"quaternion\": quaternion, \"scale\": scale}\n\n    def quaternionToRotationMatrix(self, q):\n        \"\"\" Return a rotation matrix from a quaternion. \"\"\"\n        rotMat3x3 = q.toRotationMatrix()\n        return QMatrix4x4(\n            rotMat3x3(0, 0), rotMat3x3(0, 1), rotMat3x3(0, 2), 0,\n            rotMat3x3(1, 0), rotMat3x3(1, 1), rotMat3x3(1, 2), 0,\n            rotMat3x3(2, 0), rotMat3x3(2, 1), rotMat3x3(2, 2), 0,\n            0,               0,               0,               1\n        )\n"
  },
  {
    "path": "meshroom/ui/components/scriptEditor.py",
    "content": "\"\"\" Script Editor for Meshroom.\n\"\"\"\n# STD\nfrom io import StringIO\nfrom contextlib import redirect_stdout\nimport traceback\n\n# Qt\nfrom PySide6 import QtCore, QtGui\nfrom PySide6.QtCore import Property, QObject, Slot, Signal, QSettings\n\n\nclass ScriptEditorManager(QObject):\n    \"\"\" Manages the script editor history and logs. \"\"\"\n\n    _GROUP = \"ScriptEditor\"\n    _KEY = \"script\"\n\n    def __init__(self, parent=None):\n        super(ScriptEditorManager, self).__init__(parent=parent)\n        self._history = []\n        self._index = -1\n\n        self._globals = {}\n        self._locals = {}\n\n    # Protected\n    def _defaultScript(self):\n        \"\"\" Returns the default script for the script editor. \"\"\"\n        lines = (\n            \"from meshroom.ui import uiInstance\\n\",\n            \"graph = uiInstance.activeProject.graph\",\n            \"for node in graph.nodes:\",\n            \"    print(node.name)\"\n        )\n\n        return \"\\n\".join(lines)\n\n    def _lastScript(self):\n        \"\"\" Returns the last script from the user settings. \"\"\"\n        settings = QSettings()\n        settings.beginGroup(self._GROUP)\n        return settings.value(self._KEY)\n\n    def _hasPreviousScript(self):\n        \"\"\" Returns whether there is a previous script available.\n        \"\"\"\n        # If the current index is greater than the first\n        return self._index > 0\n\n    def _hasNextScript(self):\n        \"\"\" Returns whether there is a new script available to load. \"\"\"\n        # If the current index is lower than the available indexes\n        return self._index < (len(self._history) - 1)\n\n    # Public\n    @Slot(str, result=str)\n    def process(self, script):\n        \"\"\" Execute the provided input script, capture the output from the standard output, and return it. \"\"\"\n        # Saves the state if an exception has occurred\n        exception = False\n\n        stdout = StringIO()\n        with redirect_stdout(stdout):\n            try:\n                exec(script, self._globals, self._locals)\n            except Exception:\n                # Update that we have an exception that is thrown\n                exception = True\n                # Print the backtrace\n                traceback.print_exc(file=stdout)\n\n        result = stdout.getvalue().strip()\n\n        # Strip out additional part\n        if exception:\n            # We know that we are executing the above statement and that caused the exception\n            # What we want to show to the user is just the part that happened while executing the script\n            # So just split with the last part and show it to the user\n            result = result.split(\"self._locals)\", 1)[-1]\n\n        # Add the script to the history and move up the index to the top of history stack\n        self._history.append(script)\n        self._index = len(self._history)\n        self.scriptIndexChanged.emit()\n\n        return result\n\n    @Slot()\n    def clearHistory(self):\n        \"\"\" Clear the list of executed scripts and reset the index. \"\"\"\n        self._history = []\n        self._index = -1\n\n    @Slot(result=str)\n    def getNextScript(self):\n        \"\"\"\n        Get the next entry in the history of executed scripts and update the index adequately.\n        If there is no next entry, return an empty string.\n        \"\"\"\n        if self._index + 1 < len(self._history) and len(self._history) > 0:\n            self._index = self._index + 1\n            self.scriptIndexChanged.emit()\n            return self._history[self._index]\n        return \"\"\n\n    @Slot(result=str)\n    def getPreviousScript(self):\n        \"\"\"\n        Get the previous entry in the history of executed scripts and update the index adequately.\n        If there is no previous entry, return an empty string.\n        \"\"\"\n        if self._index - 1 >= 0 and self._index - 1 < len(self._history):\n            self._index = self._index - 1\n            self.scriptIndexChanged.emit()\n            return self._history[self._index]\n        elif self._index == 0 and len(self._history):\n            return self._history[self._index]\n        return \"\"\n\n    @Slot(result=str)\n    def loadLastScript(self):\n        \"\"\" Returns the last executed script from the prefs. \"\"\"\n        return self._lastScript() or self._defaultScript()\n\n    @Slot(str)\n    def saveScript(self, script):\n        \"\"\"\n        Returns the last executed script from the prefs.\n\n        Args:\n            script (str): The script to save.\n        \"\"\"\n        settings = QSettings()\n        settings.beginGroup(self._GROUP)\n        settings.setValue(self._KEY, script)\n        settings.sync()\n\n    scriptIndexChanged = Signal()\n\n    hasPreviousScript = Property(bool, _hasPreviousScript, notify=scriptIndexChanged)\n    hasNextScript = Property(bool, _hasNextScript, notify=scriptIndexChanged)\n\n\nclass CharFormat(QtGui.QTextCharFormat):\n    \"\"\" The Char format for the syntax.\n    \"\"\"\n\n    def __init__(self, color, bold=False, italic=False):\n        \"\"\" Constructor.\n        \"\"\"\n        super().__init__()\n\n        self._color = QtGui.QColor()\n        self._color.setNamedColor(color)\n\n        # Update the Foreground color\n        self.setForeground(self._color)\n\n        # The font characteristics\n        if bold:\n            self.setFontWeight(QtGui.QFont.Bold)\n        if italic:\n            self.setFontItalic(True)\n\n\nclass PySyntaxHighlighter(QtGui.QSyntaxHighlighter):\n    \"\"\" Syntax highlighter for the Python language. \"\"\"\n\n    # Syntax styles that can be shared by all languages\n    STYLES = {\n        \"keyword\"   : CharFormat(\"#9e59b3\"),               # Purple\n        \"operator\"  : CharFormat(\"#2cb8a0\"),               # Teal\n        \"brace\"     : CharFormat(\"#2f807e\"),               # Dark Aqua\n        \"defclass\"  : CharFormat(\"#c9ba49\", bold=True),    # Yellow\n        \"deffunc\"   : CharFormat(\"#4996c9\", bold=True),    # Blue\n        \"string\"    : CharFormat(\"#7dbd39\"),               # Greeny\n        \"comment\"   : CharFormat(\"#8d8d8d\", italic=True),  # Dark Grayish\n        \"self\"      : CharFormat(\"#e6ba43\", italic=True),  # Yellow\n        \"numbers\"   : CharFormat(\"#d47713\"),               # Orangish\n    }\n\n    # Python keywords\n    keywords = (\n        \"and\", \"assert\", \"break\", \"class\", \"continue\", \"def\",\n        \"del\", \"elif\", \"else\", \"except\", \"exec\", \"finally\",\n        \"for\", \"from\", \"global\", \"if\", \"import\", \"in\",\n        \"is\", \"lambda\", \"not\", \"or\", \"pass\", \"print\",\n        \"raise\", \"return\", \"try\", \"while\", \"yield\",\n        \"None\", \"True\", \"False\",\n    )\n\n    # Python operators\n    operators = (\n        \"=\",\n        # Comparison\n        \"==\", \"!=\", \"<\", \"<=\", \">\", \">=\",\n        # Arithmetic\n        r\"\\+\", \"-\", r\"\\*\", \"/\", \"//\", r\"\\%\", r\"\\*\\*\",\n        # In-place\n        r\"\\+=\", \"-=\", r\"\\*=\", \"/=\", r\"\\%=\",\n        # Bitwise\n        r\"\\^\", r\"\\|\", r\"\\&\", r\"\\~\", r\">>\", r\"<<\",\n    )\n\n    # Python braces\n    braces = (r\"\\{\", r\"\\}\", r\"\\(\", r\"\\)\", r\"\\[\", r\"\\]\")\n\n    def __init__(self, parent=None):\n        \"\"\"\n        Constructor.\n\n        Keyword Args:\n            parent (QObject): The QObject parent from the QML side.\n        \"\"\"\n        super().__init__(parent)\n\n        # The Document to highlight\n        self._document = None\n\n        # Build a QRegularExpression for each of the pattern\n        self._rules = self.__rules()\n\n    # Private\n    def __rules(self):\n        \"\"\" Formatting rules. \"\"\"\n        # Set of rules accordind to which the highlight should occur\n        rules = []\n\n        # Keyword rules\n        rules += [(QtCore.QRegularExpression(r\"\\b\" + w + r\"\\s\"), 0, PySyntaxHighlighter.STYLES[\"keyword\"]) for w in PySyntaxHighlighter.keywords]\n        # Operator rules\n        rules += [(QtCore.QRegularExpression(o), 0, PySyntaxHighlighter.STYLES[\"operator\"]) for o in PySyntaxHighlighter.operators]\n        # Braces\n        rules += [(QtCore.QRegularExpression(b), 0, PySyntaxHighlighter.STYLES[\"brace\"]) for b in PySyntaxHighlighter.braces]\n\n        # All other rules\n        rules += [\n            # self\n            (QtCore.QRegularExpression(r'\\bself\\b'), 0, PySyntaxHighlighter.STYLES[\"self\"]),\n\n            # 'def' followed by an identifier\n            (QtCore.QRegularExpression(r'\\bdef\\b\\s*(\\w+)'), 1, PySyntaxHighlighter.STYLES[\"deffunc\"]),\n            # 'class' followed by an identifier\n            (QtCore.QRegularExpression(r'\\bclass\\b\\s*(\\w+)'), 1, PySyntaxHighlighter.STYLES[\"defclass\"]),\n\n            # Numeric literals\n            (QtCore.QRegularExpression(r'\\b[+-]?[0-9]+[lL]?\\b'), 0, PySyntaxHighlighter.STYLES[\"numbers\"]),\n            (QtCore.QRegularExpression(r'\\b[+-]?0[xX][0-9A-Fa-f]+[lL]?\\b'), 0, PySyntaxHighlighter.STYLES[\"numbers\"]),\n            (QtCore.QRegularExpression(r'\\b[+-]?[0-9]+(?:\\.[0-9]+)?(?:[eE][+-]?[0-9]+)?\\b'), 0, PySyntaxHighlighter.STYLES[\"numbers\"]),\n\n            # Double-quoted string, possibly containing escape sequences\n            (QtCore.QRegularExpression(r'\"[^\"\\\\]*(\\\\.[^\"\\\\]*)*\"'), 0, PySyntaxHighlighter.STYLES[\"string\"]),\n            # Single-quoted string, possibly containing escape sequences\n            (QtCore.QRegularExpression(r\"'[^'\\\\]*(\\\\.[^'\\\\]*)*'\"), 0, PySyntaxHighlighter.STYLES[\"string\"]),\n\n            # From '#' until a newline\n            (QtCore.QRegularExpression(r'#[^\\n]*'), 0, PySyntaxHighlighter.STYLES['comment']),\n        ]\n\n        return rules\n\n    def highlightBlock(self, text):\n        \"\"\"\n        Applies syntax highlighting to the given block of text.\n\n        Args:\n            text (str): The text to highlight.\n        \"\"\"\n        # Do other syntax formatting\n        for expression, nth, _format in self._rules:\n            # fetch the index of the expression in text\n            match = expression.match(text, 0)\n            index = match.capturedStart()\n\n            while index >= 0:\n                # We actually want the index of the nth match\n                index = match.capturedStart(nth)\n                length = len(match.captured(nth))\n                self.setFormat(index, length, _format)\n                # index = expression.indexIn(text, index + length)\n                match = expression.match(text, index + length)\n                index = match.capturedStart()\n\n    def textDoc(self):\n        \"\"\" Returns the document being highlighted. \"\"\"\n        return self._document\n\n    def setTextDocument(self, document):\n        \"\"\"\n        Sets the document on the Highlighter.\n\n        Args:\n            document (QtQuick.QQuickTextDocument): The document from the QML engine.\n        \"\"\"\n        # If the same document is provided again\n        if document == self._document:\n            return\n\n        # Update the class document\n        self._document = document\n\n        # Set the document on the highlighter\n        self.setDocument(self._document.textDocument())\n\n        # Emit that the document is now changed\n        self.textDocumentChanged.emit()\n\n    # Signals\n    textDocumentChanged = Signal()\n\n    # Property\n    textDocument = Property(QObject, textDoc, setTextDocument, notify=textDocumentChanged)\n"
  },
  {
    "path": "meshroom/ui/components/shapes/__init__.py",
    "content": "from .shapeFilesHelper import (\n    ShapeFilesHelper\n)\nfrom .shapeViewerHelper import (\n    ShapeViewerHelper\n)"
  },
  {
    "path": "meshroom/ui/components/shapes/shapeFile.py",
    "content": "from meshroom.common import BaseObject, Property, Variant, Signal, ListModel, Slot\nfrom meshroom.core.attribute import Attribute\nimport json, os, re\n\nclass ShapeFile(BaseObject):\n    \"\"\"\n    List of shapes provided by a json file attribute.\n    \"\"\"\n\n    class ShapeData(BaseObject):\n        \"\"\"\n        Single shape with its properties and observations.\n        \"\"\"\n        def __init__(self, name: str, type: str, properties={}, observations={}, parent=None):\n            super().__init__(parent)\n            # View id\n            self._viewId = \"-1\"\n            # Shape name\n            self._name = name\n            # Shape type (Point2d, Line2d, Rectangle, Circle, etc.)\n            self._type = type\n            # Shape properties (color, stroke, etc.)\n            self._properties = properties\n            # Shape observations {viewId: observation{x, y, radius, etc.}}\n            self._observations = observations\n            # Shape keyabale\n            self._keyable = len(observations) > 0\n            # Shape visible\n            self._visible = True\n\n        def _getVisible(self) -> bool:\n            \"\"\"\n            Return whether the shape is visible for display.\n            \"\"\"\n            return self._visible\n\n        def _setVisible(self, visible:bool):\n            \"\"\"\n            Set the shape visibility for display.\n            \"\"\"\n            self._visible = visible\n            self.visibleChanged.emit()\n\n        def setViewId(self, viewId: str):\n            \"\"\"\n            Set the shape current view id.\n            \"\"\"\n            self._viewId = viewId\n            self.viewIdChanged.emit()\n\n        def _getObservation(self):\n            \"\"\"\n            Get the shape current observation.\n            \"\"\"\n            if self._keyable:\n                return self._observations.get(self._viewId, None)\n            return self._properties\n\n        def _getNbObservations(self):\n            \"\"\"\n            Return the shape number of observations.\n            \"\"\"\n            if self._keyable:\n                return len(self._observations)\n            return 1\n\n        @Slot(str, result=bool)\n        def hasObservation(self, key: str) -> bool:\n            \"\"\"\n            Return whether the shape has an observation for the given key.\n            \"\"\"\n            if self._keyable:\n                return self._observations.get(self._viewId, None) is not None\n            return True\n\n        # Signals\n        viewIdChanged = Signal()\n        visibleChanged = Signal()\n\n        # Properties\n        # The shape name.\n        name = Property(str, lambda self: self._name, constant=True)\n        # The shape label.\n        label = Property(str, lambda self: self._name, constant=True)\n        # The shape type (Point2d, Line2d, Rectangle, Circle, etc.).\n        type = Property(str, lambda self: self._type, constant=True)\n        # The shape properties (color, stroke, etc.).\n        properties = Property(Variant, lambda self: self._properties, constant=True)\n        # The shape current observation.\n        observation = Property(Variant, _getObservation, notify=viewIdChanged)\n        # Whether the shape is keyabale (multiple observations).\n        observationKeyable = Property(bool,lambda self: self._keyable, constant=True)\n        # The shape list of observation keys.\n        observationKeys = Property(Variant, lambda self: [key for key in self._observations], constant=True)\n        # The number of observation defined.\n        nbObservations = Property(int, _getNbObservations, constant=True)\n        # Whether the shape is displayable.\n        isVisible = Property(bool, _getVisible, _setVisible, notify=visibleChanged)\n\n    def __init__(self, fileAttribute: Attribute, viewId: str, parent=None):\n        super().__init__(parent)\n        # List of shapes\n        self._shapes = ListModel(parent=self)\n        # File attribute\n        self._fileAttribute = fileAttribute\n        # Current view id\n        self._viewId = viewId\n        # Shapes visible\n        self._visible = True\n        # Populate the model from the provided file\n        self._loadShapesFromJsonFile()\n        # Update viewId for all shapes\n        self.setViewId(viewId)\n        # Connect file attribute value\n        fileAttribute.valueChanged.connect(self._loadShapesFromJsonFile)\n\n    def _getVisible(self) -> bool:\n        \"\"\"\n        Return whether the shape file is visible for display.\n        \"\"\"\n        return self._visible\n\n    def _setVisible(self, visible:bool):\n        \"\"\"\n        Set the shape file visibility for display.\n        \"\"\"\n        self._visible = visible\n        for shape in self._shapes:\n            shape.isVisible = visible\n        self.visibleChanged.emit()\n\n    def _getBasename(self) -> str:\n        \"\"\"\n        Get file attribute basename.\n        \"\"\"\n        return os.path.basename(self._fileAttribute.value)\n\n    def setViewId(self, viewId: str):\n        \"\"\"\n        Set the current view id for all shapes of the file.\n        \"\"\"\n        for shape in self._shapes:\n            shape.setViewId(viewId)\n\n    @Slot()\n    def _loadShapesFromJsonFile(self):\n        \"\"\"\n        Load shapes from the json file.\n        \"\"\"\n        def convertNumericStrings(obj):\n            \"\"\"\n            Helper function to convert numeric strings.\n            \"\"\"\n            if isinstance(obj, dict):\n                return {k: convertNumericStrings(v) for k, v in obj.items()}\n            elif isinstance(obj, list):\n                return [convertNumericStrings(elem) for elem in obj]\n            elif isinstance(obj, str):\n                # Check for int or float\n                if re.fullmatch(r'-?\\d+', obj):\n                    return int(obj)\n                elif re.fullmatch(r'-?\\d+\\.\\d*', obj):\n                    return float(obj)\n            return obj\n\n        # Clear model\n        self._shapes.clear()\n        # Load from json file\n        if os.path.exists(self._fileAttribute.value):\n            try:\n                with open(self._fileAttribute.value, \"r\") as f:\n                    # Load json\n                    loadedData = json.load(f)\n                    # Handle both formats: direct array or object with \"shapes\" key\n                    if isinstance(loadedData, dict) and \"shapes\" in loadedData:\n                        shapesArray = loadedData[\"shapes\"]\n                    elif isinstance(loadedData, list):\n                        shapesArray = loadedData\n                    else:\n                        print(\"Invalid JSON format: expected array or object with 'shapes' key\")\n                        self.fileChanged.emit()\n                        return\n                    # Build shapes from proper shapes array\n                    for itemData in convertNumericStrings(shapesArray):\n                        name = itemData.get(\"name\", \"unknown\")\n                        type = itemData.get(\"type\", \"unknown\")\n                        properties = itemData.get(\"properties\", {})\n                        observations = itemData.get(\"observations\", {})\n                        self._shapes.append(ShapeFile.ShapeData(name, type, properties, observations, self._shapes))\n            except FileNotFoundError:\n                print(\"No shapes found to load.\")\n            except json.JSONDecodeError as err:\n                print(f\"Error decoding JSON: {err}\")\n            except Exception as exc:\n                print(f\"Error loading shapes: {exc}\")\n        self.fileChanged.emit()\n\n    # Signals\n    fileChanged = Signal()\n    visibleChanged = Signal()\n\n    # Properties\n    # The model type, always ShapeFile.\n    type = Property(str, lambda self: \"ShapeFile\", constant=True)\n    # The corresponding File attribute label.\n    label = Property(str, lambda self: self._fileAttribute.label, constant=True)\n    # The file basename.\n    basename = Property(str, _getBasename, notify=fileChanged)\n    # The list of shapes.\n    shapes = Property(Variant, lambda self: self._shapes, notify=fileChanged)\n    # Whether the file has shapes.\n    isEmpty = Property(bool, lambda self: len(self._shapes) <= 0, notify=fileChanged)\n    # Whether the file is displayable.\n    isVisible = Property(bool, _getVisible, _setVisible, notify=visibleChanged)"
  },
  {
    "path": "meshroom/ui/components/shapes/shapeFilesHelper.py",
    "content": "from meshroom.ui.scene import Scene\nfrom meshroom.common import BaseObject, Property, Variant, Signal, ListModel, Slot\nfrom meshroom.core.attribute import GroupAttribute, ListAttribute\nfrom shiboken6 import isValid\nfrom .shapeFile import ShapeFile\n\n# Filter runtime warning when closing Meshroom with active shape files\nimport warnings\nwarnings.filterwarnings(\"ignore\", message=\".*Failed to disconnect.*\", category=RuntimeWarning)\n\nclass ShapeFilesHelper(BaseObject):\n    \"\"\"\n    Manages active project selected node shape files.\n    \"\"\"\n\n    def __init__(self, activeProject:Scene, parent=None):\n        super().__init__(parent)\n        self._activeProject = activeProject\n        self._currentNode = activeProject.selectedNode\n        self._shapeFiles = ListModel()\n        self._activeProject.selectedViewIdChanged.connect(self._onSelectedViewIdChanged)\n        self._activeProject.selectedNodeChanged.connect(self._onSelectedNodeChanged)\n\n    def _loadShapeFilesFromAttributes(self, attributes):\n        \"\"\"\n        Search for File attribute with shape file semantic in selected node attributes.\n        Update the model based on the shape files found.\n        \"\"\"\n        for attribute in attributes:\n            if isinstance(attribute, (ListAttribute, GroupAttribute)):\n                self._loadShapeFilesFromAttributes(attribute.value)\n            elif attribute.type == \"File\" and attribute.desc.semantic == \"shapeFile\":\n                self._shapeFiles.append(ShapeFile(fileAttribute=attribute,\n                                                  viewId=self._activeProject.selectedViewId,\n                                                  parent=self._shapeFiles))\n\n    @Slot()\n    def _loadShapeFiles(self):\n        \"\"\"Load/Reload active project selected node shape files.\"\"\"\n        # clear shapeFiles model\n        self._shapeFiles.clear()\n        # load node shape files\n        if self._activeProject.selectedNode:\n            self._loadShapeFilesFromAttributes(self._activeProject.selectedNode.attributes)\n        self.nodeShapeFilesChanged.emit()\n\n    @Slot()\n    def _onSelectedViewIdChanged(self):\n        \"\"\"Callback when the active project selected view id changes.\"\"\"\n        for shapeFile in self._shapeFiles:\n            shapeFile.setViewId(self._activeProject.selectedViewId)\n\n    @Slot()\n    def _onSelectedNodeChanged(self):\n        \"\"\"Callback when the active project selected node changes.\"\"\"\n        # disconnect internalFolderChanged signal\n        if self._currentNode is not None:\n            try:\n                self._currentNode.internalFolderChanged.disconnect(self._loadShapeFiles)\n            except RuntimeError:\n                # Signal was already disconnected or never connected\n                pass\n        # check selected node exists and selected node has displayable shape\n        if self._activeProject.selectedNode is None or not self._activeProject.selectedNode.hasDisplayableShape:\n            # clear shapeFiles model\n            if isValid(self._shapeFiles):\n                self._shapeFiles.clear()\n            # clear current node\n            self._currentNode = None\n            return\n        # update current node\n        self._currentNode = self._activeProject.selectedNode\n        # connect internalFolderChanged signal\n        try:\n            self._currentNode.internalFolderChanged.connect(self._loadShapeFiles)\n        except RuntimeError:\n            # Signal was already disconnected or never connected\n            pass\n        # load node shape files\n        self._loadShapeFiles()\n\n    # Properties and signals\n    nodeShapeFilesChanged = Signal()\n    nodeShapeFiles = Property(Variant, lambda self: self._shapeFiles, notify=nodeShapeFilesChanged)"
  },
  {
    "path": "meshroom/ui/components/shapes/shapeViewerHelper.py",
    "content": "from meshroom.common import BaseObject, Property, Variant, Signal, Slot\n\nclass ShapeViewerHelper(BaseObject):\n    \"\"\"\n    Manages interactions with the qml ShapeViewer (2d Viewer).\n    - Handle shape selection.\n    - Handle shape observation initialization.\n    \"\"\"\n\n    def __init__(self, parent=None):\n        super().__init__(parent)\n        self._selectedShapeName = \"\"\n        self._containerWidth = 0.0\n        self._containerHeight = 0.0\n        self._containerScale = 0.0\n\n    def _getSelectedShapeName(self) -> str:\n        return self._selectedShapeName\n\n    def _getContainerWidth(self) -> float:\n        return self._containerWidth\n\n    def _getContainerHeight(self) -> float:\n        return self._containerHeight\n\n    def _getContainerScale(self) -> float:\n        return self._containerScale\n\n    def _setSelectedShapeName(self, shapeName:str):\n        self._selectedShapeName = shapeName\n        self.selectedShapeNameChanged.emit()\n\n    def _setContainerWidth(self, width: float):\n        self._containerWidth = width\n        self.containerWidthChanged.emit()\n\n    def _setContainerHeight(self, height: float):\n        self._containerHeight= height\n        self.containerHeightChanged.emit()\n\n    def _setContainerScale(self, scale: float):\n        self._containerScale = scale\n        self.containerScaleChanged.emit()\n\n    @Slot(str, result=Variant)\n    def getDefaultObservation(self, shapeType: str) -> Variant:\n        \"\"\"\n        Helper function to create a shape default observation.\n        \"\"\"\n        match shapeType:\n            case \"Point2d\":\n                return { \"x\": self._containerWidth * 0.5, \"y\": self._containerHeight * 0.5}\n            case \"Line2d\":\n                return { \"a\": { \"x\": self._containerWidth * 0.4, \"y\": self._containerHeight * 0.4},\n                         \"b\": { \"x\": self._containerWidth * 0.6, \"y\": self._containerHeight * 0.6}}\n            case \"Circle\":\n                return { \"center\": {\"x\": self._containerWidth * 0.5, \"y\": self._containerHeight * 0.5},\n                         \"radius\": self._containerWidth * 0.1}\n            case \"Rectangle\":\n                return { \"center\": { \"x\": self._containerWidth * 0.5, \"y\": self._containerHeight * 0.5},\n                         \"size\": { \"width\": self._containerWidth * 0.2, \"height\": self._containerHeight * 0.2}}\n        return None\n\n    # Properties and signals\n    selectedShapeNameChanged = Signal()\n    selectedShapeName = Property(str, _getSelectedShapeName, _setSelectedShapeName, notify=selectedShapeNameChanged)\n\n    containerWidthChanged = Signal()\n    containerWidth = Property(float, _getContainerWidth, _setContainerWidth, notify=containerWidthChanged)\n\n    containerHeightChanged = Signal()\n    containerHeight = Property(float, _getContainerHeight, _setContainerHeight, notify=containerHeightChanged)\n\n    containerScaleChanged = Signal()\n    containerScale = Property(float, _getContainerScale, _setContainerScale, notify=containerScaleChanged)\n"
  },
  {
    "path": "meshroom/ui/components/thumbnail.py",
    "content": "from meshroom.common import Signal\n\nfrom PySide6.QtCore import QObject, Slot, QSize, QUrl, Qt, QStandardPaths\nfrom PySide6.QtGui import QImageReader, QImageWriter\n\nimport os\nfrom pathlib import Path\nimport stat\nimport hashlib\nimport time\nimport logging\nfrom threading import Thread\nfrom multiprocessing.pool import ThreadPool\n\n\nclass ThumbnailCache(QObject):\n    \"\"\"ThumbnailCache provides an abstraction for the thumbnail cache on disk, available in QML.\n\n    For a given image file, it ensures the corresponding thumbnail exists (by creating it if necessary)\n    and gives access to it.\n    Since creating thumbnails can be long (as it requires to read the full image from disk)\n    it is performed asynchronously to avoid blocking the main thread.\n\n    The default cache location can be overriden with the MESHROOM_THUMBNAIL_DIR environment variable.\n\n    This class also takes care of cleaning the thumbnail directory,\n    i.e. scanning this directory and removing thumbnails that have not been used for too long.\n    This operation also ensures that the number of thumbnails on disk does not exceed a certain limit,\n    by removing thumbnails if necessary (from least recently used to most recently used).\n    Since this operation is done at application startup, it is also performed asynchronously.\n\n    The default time limit is 90 days,\n    and can be overriden with the MESHROOM_THUMBNAIL_TIME_LIMIT environment variable.\n\n    The default maximum number of thumbnails on disk is 100000,\n    and can be overriden with the MESHROOM_MAX_THUMBNAILS_ON_DISK.\n\n    The main use case for thumbnails in Meshroom is in the ImageGallery.\n    \"\"\"\n\n    # Thumbnail cache directory\n    # Cannot be initialized here as it depends on the organization and application names\n    thumbnailDir = ''\n\n    # Thumbnail dimensions limit (the actual dimensions of a thumbnail will depend on the aspect ratio)\n    thumbnailSize = QSize(256, 256)\n\n    # Time limit for thumbnail storage on disk, expressed in days\n    storageTimeLimit = 90\n\n    # Maximum number of thumbnails in the cache directory\n    maxThumbnailsOnDisk = 100000\n\n    # Signal to notify listeners that a thumbnail was created and written on disk\n    # This signal has two argument:\n    # - the url of the image that the thumbnail is associated to\n    # - an identifier for the caller, e.g. the component that sent the request (useful for faster dispatch in QML)\n    thumbnailCreated = Signal(QUrl, int)\n\n    # Threads info and LIFO structure for running clean and createThumbnail asynchronously\n    requests = []\n    cleaningThread = None\n    workerThreads = ThreadPool(processes=3)\n\n    def __del__(self):\n        self.workerThreads.terminate()\n        self.workerThreads.join()\n\n    @staticmethod\n    def initialize():\n        \"\"\"Initialize static fields in cache class and cache directory on disk.\"\"\"\n        # Thumbnail directory: default or user specified\n        dir = os.getenv('MESHROOM_THUMBNAIL_DIR')\n        if dir is not None:\n            ThumbnailCache.thumbnailDir = dir\n        else:\n            ThumbnailCache.thumbnailDir = os.path.join(QStandardPaths.writableLocation(QStandardPaths.CacheLocation),\n                                                       'thumbnails')\n\n        # User specifed time limit for thumbnails on disk (expressed in days)\n        timeLimit = os.getenv('MESHROOM_THUMBNAIL_TIME_LIMIT')\n        if timeLimit is not None:\n            ThumbnailCache.storageTimeLimit = float(timeLimit)\n\n        # User specifed maximum number of thumbnails on disk\n        maxOnDisk = os.getenv('MESHROOM_MAX_THUMBNAILS_ON_DISK')\n        if maxOnDisk is not None:\n            ThumbnailCache.maxThumbnailsOnDisk = int(maxOnDisk)\n\n        # Clean thumbnail directory\n        # This is performed asynchronously to avoid freezing the app at startup\n        ThumbnailCache.cleaningThread = Thread(target=ThumbnailCache.clean)\n        ThumbnailCache.cleaningThread.start()\n\n        # Make sure the thumbnail directory exists before writing into it\n        try:\n            os.makedirs(ThumbnailCache.thumbnailDir, exist_ok=True)\n        except OSError:\n            logging.warning(f'[ThumbnailCache] Failed to create directory: {ThumbnailCache.thumbnailDir}')\n            pass\n\n    @staticmethod\n    def clean():\n        \"\"\"Scan the thumbnail directory and:\n        1. remove all thumbnails that have not been used for more than storageTimeLimit days\n        2. ensure that the number of thumbnails on disk does not exceed maxThumbnailsOnDisk.\n        \"\"\"\n        # Check if thumbnail directory exists\n        if not os.path.exists(ThumbnailCache.thumbnailDir):\n            logging.debug('[ThumbnailCache] Thumbnail directory does not exist yet.')\n            return\n\n        # Get current time\n        now = time.time()\n\n        # Scan thumbnail directory and gather all thumbnails to remove\n        toRemove = []\n        remaining = []\n        for f_name in os.listdir(ThumbnailCache.thumbnailDir):\n            pathname = os.path.join(ThumbnailCache.thumbnailDir, f_name)\n\n            # System call to get current item info\n            f_stat = os.stat(pathname, follow_symlinks=False)\n\n            # Check if this is a regular file\n            if not stat.S_ISREG(f_stat.st_mode):\n                continue\n\n            # Compute storage duration since last usage of thumbnail\n            lastUsage = f_stat.st_mtime\n            storageTime = now - lastUsage\n            # logging.debug(f'[ThumbnailCache] Thumbnail {f_name} has been stored for {storageTime}s')\n\n            if storageTime > ThumbnailCache.storageTimeLimit * 3600 * 24:\n                # Mark as removable if storage time exceeds limit\n                logging.debug(f'[ThumbnailCache] {f_name} exceeded storage time limit')\n                toRemove.append(pathname)\n            else:\n                # Store path and last usage time for potentially sorting and removing later\n                remaining.append((pathname, lastUsage))\n\n        # Remove all thumbnails marked as removable\n        for path in toRemove:\n            logging.debug(f'[ThumbnailCache] Remove {path}')\n            try:\n                os.remove(path)\n            except FileNotFoundError:\n                logging.error(f'[ThumbnailCache] Tried to remove {path} but this file does not exist')\n\n        # Check if number of thumbnails on disk exceeds limit\n        if len(remaining) > ThumbnailCache.maxThumbnailsOnDisk:\n            nbToRemove = len(remaining) - ThumbnailCache.maxThumbnailsOnDisk\n            logging.debug(\n                f'[ThumbnailCache] Too many thumbnails: {len(remaining)} remaining, {nbToRemove} will be removed')\n\n            # Sort by last usage order and remove excess\n            remaining.sort(key=lambda elt: elt[1])\n            for i in range(nbToRemove):\n                path = remaining[i][0]\n                logging.debug(f'[ThumbnailCache] Remove {path}')\n                try:\n                    os.remove(path)\n                except FileNotFoundError:\n                    logging.error(f'[ThumbnailCache] Tried to remove {path} but this file does not exist')\n\n    @staticmethod\n    def thumbnailPath(imgPath):\n        \"\"\"Use SHA1 hashing to associate a unique thumbnail to an image.\n\n        Args:\n            imgPath (str): filepath to the input image\n\n        Returns:\n            str: filepath to the corresponding thumbnail\n        \"\"\"\n        digest = hashlib.sha1(imgPath.encode('utf-8')).hexdigest()\n        path = os.path.join(ThumbnailCache.thumbnailDir, f'{digest}.jpg')\n        return path\n\n    @staticmethod\n    def removeOutdated(imgPath, path):\n        \"\"\"Remove thumbnail if its corresponding image has been modified after thumbnail creation.\n\n        Args:\n            imgPath (str): filepath to the input image\n            path (str): filepath to the corresponding thumbnail\n        \"\"\"\n        try:\n            if os.path.getmtime(imgPath) > os.path.getmtime(path):\n                os.remove(path)\n        except OSError:\n            return\n        except FileNotFoundError:\n            return\n\n    @staticmethod\n    def checkThumbnail(path):\n        \"\"\"\n        Check if a thumbnail already exists on disk, and if so update its last modification time.\n\n        Args:\n            path (str): filepath to the thumbnail\n\n        Returns:\n            (bool): whether the thumbnail exists on disk or not\n        \"\"\"\n        if os.path.exists(path):\n            # Update last modification time\n            Path(path).touch(exist_ok=True)\n            return True\n        return False\n\n    @Slot(QUrl, int, result=QUrl)\n    def thumbnail(self, imgSource, callerID):\n        \"\"\"\n        Retrieve the filepath of the thumbnail corresponding to a given image.\n\n        If the thumbnail does not exist on disk, it will be created asynchronously.\n        When this is done, the thumbnailCreated signal is emitted.\n\n        Args:\n            imgSource (QUrl): location of the input image\n            callerID (int): identifier for the object that requested the thumbnail\n\n        Returns:\n            QUrl: location of the corresponding thumbnail if it exists, otherwise None\n        \"\"\"\n        if not imgSource.isValid():\n            return None\n\n        if not os.path.exists(ThumbnailCache.thumbnailDir):\n            return imgSource\n\n        imgPath = imgSource.toLocalFile()\n        path = ThumbnailCache.thumbnailPath(imgPath)\n\n        # Remove thumbnail in case it is outdated (i.e. if image was modified)\n        ThumbnailCache.removeOutdated(imgPath, path)\n\n        # Check if thumbnail already exists (and update its last modification time)\n        if ThumbnailCache.checkThumbnail(path):\n            source = QUrl.fromLocalFile(path)\n            return source\n\n        # Thumbnail does not exist\n        # Create request and submit to worker threads\n        ThumbnailCache.requests.append((imgSource, callerID))\n        ThumbnailCache.workerThreads.apply_async(func=self.handleRequestsAsync)\n\n        return None\n\n    def createThumbnail(self, imgSource, callerID):\n        \"\"\"\n        Load an image, resize it to thumbnail dimensions and save the result in the cache directory.\n\n        Args:\n            imgSource (QUrl): location of the input image\n            callerID (int): identifier for the object that requested the thumbnail\n        \"\"\"\n        imgPath = imgSource.toLocalFile()\n        path = ThumbnailCache.thumbnailPath(imgPath)\n\n        # Check if thumbnail already exists (it may have been created by another thread)\n        if ThumbnailCache.checkThumbnail(path):\n            self.thumbnailCreated.emit(imgSource, callerID)\n            return path\n\n        logging.debug(f'[ThumbnailCache] Creating thumbnail {path} for image {imgPath}')\n\n        # Initialize image reader object\n        reader = QImageReader()\n        reader.setFileName(imgPath)\n        reader.setAutoTransform(True)\n\n        # Read image and check for potential errors\n        img = reader.read()\n        if img.isNull():\n            logging.error(f'[ThumbnailCache] Error when reading image: {reader.errorString()}')\n            return \"\"\n\n        # Scale image while preserving aspect ratio\n        thumbnail = img.scaled(ThumbnailCache.thumbnailSize,\n                               aspectMode=Qt.KeepAspectRatio,\n                               mode=Qt.SmoothTransformation)\n\n        # Write thumbnail to disk and check for potential errors\n        writer = QImageWriter(path)\n        success = writer.write(thumbnail)\n        if not success:\n            logging.error(f'[ThumbnailCache] Error when writing thumbnail: {writer.errorString()}')\n\n        # Notify listeners\n        self.thumbnailCreated.emit(imgSource, callerID)\n        return path\n\n    def handleRequestsAsync(self):\n        \"\"\"\n        Process thumbnail creation requests in LIFO order.\n\n        Note: this operation waits for the cleaning process to finish before starting,\n        in order to avoid synchronization issues.\n        \"\"\"\n        # Wait for cleaning thread to finish\n        if ThumbnailCache.cleaningThread is not None and ThumbnailCache.cleaningThread.is_alive():\n            ThumbnailCache.cleaningThread.join()\n\n        # Handle requests until the requests stack is empty\n        try:\n            while True:\n                req = ThumbnailCache.requests.pop()\n                self.createThumbnail(req[0], req[1])\n        except IndexError:\n            # No more request to process\n            return\n\n    @Slot()\n    def clearRequests(self):\n        \"\"\"\n        Clear all pending thumbnail creation requests.\n\n        Requests already under treatment by a worker thread will still be completed.\n        \"\"\"\n        ThumbnailCache.requests.clear()\n"
  },
  {
    "path": "meshroom/ui/graph.py",
    "content": "#!/usr/bin/env python\nfrom collections.abc import Iterable\nimport logging\nimport os\nimport re\nimport json\nfrom enum import Enum\nfrom threading import Thread, Event, Lock\nfrom multiprocessing.pool import ThreadPool\nfrom typing import Optional, Union\nfrom collections.abc import Iterator\nfrom collections import OrderedDict\n\nfrom PySide6.QtCore import (\n    Slot,\n    QJsonValue,\n    QObject,\n    QUrl,\n    Property,\n    Signal,\n    QPoint,\n    QItemSelectionModel,\n    QItemSelection,\n)\n\nfrom meshroom.core import sessionUid\nfrom meshroom.common.qt import QObjectListModel\nfrom meshroom.core.attribute import Attribute, ListAttribute, ShapeAttribute\nfrom meshroom.core.graph import Graph, Edge, generateTempProjectFilepath\nfrom meshroom.core.graphIO import GraphIO\n\nfrom meshroom.core.taskManager import TaskManager\nfrom meshroom.core.submitter import jobManager\n\nfrom meshroom.core.node import NodeChunk, Node, Status, ExecMode, CompatibilityNode, BackdropNode, Position\nfrom meshroom.core import submitters, MrNodeType\nfrom meshroom.ui import commands\nfrom meshroom.ui.utils import makeProperty\n\n\nclass PollerRefreshStatus(Enum):\n    AUTO_ENABLED = 0  # The file watcher polls every single status file periodically\n    DISABLED = 1  # The file watcher is disabled and never polls any file\n    MINIMAL_ENABLED = 2  # The file watcher only polls status files for chunks that are either submitted or running externally\n\n\nclass FilesModTimePollerThread(QObject):\n    \"\"\"\n    Thread responsible for non-blocking polling of last modification times of a list of files.\n    Uses a Python ThreadPool internally to split tasks on multiple threads.\n    \"\"\"\n    timesAvailable = Signal(list)\n\n    def __init__(self, parent=None):\n        super().__init__(parent)\n        self._thread = None\n        self._mutex = Lock()\n        self._threadPool = ThreadPool(4)\n        self._stopFlag = Event()\n        self._refreshInterval = 5  # refresh interval in seconds\n        self._files = []\n        if submitters:\n            self._filePollerRefresh = PollerRefreshStatus.MINIMAL_ENABLED\n        else:\n            self._filePollerRefresh = PollerRefreshStatus.DISABLED\n\n    def __del__(self):\n        self._threadPool.terminate()\n        self._threadPool.join()\n\n    def start(self, files=None):\n        \"\"\"\n        Start polling thread.\n\n        Args:\n            files: the list of files to monitor\n        \"\"\"\n        if self._filePollerRefresh is PollerRefreshStatus.DISABLED:\n            return\n        if self._thread:\n            # thread already running, return\n            return\n        self._stopFlag.clear()\n        self._files = files or []\n        self._thread = Thread(target=self.run)\n        self._thread.start()\n\n    def setFiles(self, files):\n        \"\"\"\n        Set the list of files to monitor.\n\n        Args:\n            files: the list of files to monitor\n        \"\"\"\n        logging.debug(f\"FilesModTimePollerThread: Watch files {files}\")\n        with self._mutex:\n            self._files = files\n\n    def stop(self):\n        \"\"\" Request polling thread to stop. \"\"\"\n        if not self._thread:\n            return\n        self._stopFlag.set()\n        self._thread.join()\n        self._thread = None\n\n    @staticmethod\n    def getFileLastModTime(f):\n        \"\"\" Return 'mtime' of the file if it exists, -1 otherwise. \"\"\"\n        try:\n            return os.path.getmtime(f)\n        except OSError:\n            return -1\n\n    def run(self):\n        \"\"\" Poll watched files for last modification time. \"\"\"\n        while not self._stopFlag.wait(self._refreshInterval):\n            with self._mutex:\n                files = list(self._files)\n            times = self._threadPool.map(FilesModTimePollerThread.getFileLastModTime, files)\n            with self._mutex:\n                if files == self._files:\n                    self.timesAvailable.emit(times)\n\n    def onFilePollerRefreshChanged(self, value):\n        \"\"\" Stop or start the file poller depending on the new refresh status. \"\"\"\n        self._filePollerRefresh = PollerRefreshStatus(value)\n        if self._filePollerRefresh is PollerRefreshStatus.DISABLED:\n            self.stop()\n        else:\n            self.start()\n        self.filePollerRefreshReady.emit()\n\n    filePollerRefresh = Property(int, lambda self: self._filePollerRefresh.value, constant=True)\n    filePollerRefreshReady = Signal()  # The refresh status has been updated and is ready to be used\n\n\nclass NodeStatusMonitor(QObject):\n    \"\"\"\n    NodeStatusMonitor regularly check status files for modification and trigger their update on change.\n\n    When working locally, status changes are reflected through the emission of 'statusChanged' signals.\n    But when a graph is being computed externally - either via a Submitter or on another machine,\n    Status files are modified by another instance, potentially outside this machine file system scope.\n    Same goes when status files are deleted/modified manually.\n    Thus, for genericity, monitoring is based on regular polling and not file system watching.\n    \"\"\"\n    def __init__(self, parent=None):\n        super().__init__(parent)\n        self.monitorableNodes = []\n        self.monitoredFiles = {}  # Dict {filepath: node}\n        self._filesTimePoller = FilesModTimePollerThread(parent=self)\n        self._filesTimePoller.timesAvailable.connect(self.compareFilesTimes)\n        self._filesTimePoller.start()\n        self.setMonitored([])\n        self.filePollerRefreshChanged.connect(self._filesTimePoller.onFilePollerRefreshChanged)\n        self._filesTimePoller.filePollerRefreshReady.connect(self.onFilePollerRefreshUpdated)\n\n    def setWatchedFiles(self):\n        self.monitoredItems = self.getMonitoredFiles()\n        monitoredFiles = list([f for f in self.monitoredItems.keys()])\n        self._filesTimePoller.setFiles(monitoredFiles)\n\n    def setMonitored(self, nodes):\n        self.monitorableNodes = nodes\n        self.setWatchedFiles()\n\n    def stop(self):\n        \"\"\" Stop the status files monitoring. \"\"\"\n        self._filesTimePoller.stop()\n\n    def getMonitoredFiles(self):\n        monitoredItems = OrderedDict()\n        for node in self.monitorableNodes:\n            if node._chunksCreated:\n                fileItems = {c.getStatusFile(): (\"chunk\", c) for c in node._chunks}\n            else:\n                fileItems = {node.nodeStatusFile: (\"node\", node)}\n            if self.filePollerRefresh is PollerRefreshStatus.AUTO_ENABLED.value:\n                # Add everything\n                monitoredItems.update(fileItems)\n            elif self.filePollerRefresh is PollerRefreshStatus.MINIMAL_ENABLED.value:\n                # Only chunks that are run externally or local_isolated should be monitored,\n                # when run locally, status changes are already notified.\n                # Chunks with an ERROR status may be re-submitted externally and should thus still be monitored\n                for file, (_type, _item) in fileItems.items():\n                    if not _item.shouldMonitorChanges():\n                        continue\n                    monitoredItems[file] = (_type, _item)\n        return monitoredItems\n\n    def compareFilesTimes(self, times):\n        \"\"\"\n        Compare previous file modification times with results from last poll.\n        Trigger chunk status update if file was modified since.\n\n        Args:\n            times: the last modification times for currently monitored files.\n        \"\"\"\n        newRecords = dict(zip(self.monitoredItems.items(), times))\n        nodesToUpdate = set()\n        for monitoredItem, fileModTime in newRecords.items():\n            _, (_type, _item) = monitoredItem\n            if _type == \"chunk\":\n                chunk = _item\n                # update chunk status if last modification time has changed since previous record\n                if fileModTime != chunk.statusFileLastModTime:\n                    chunk.updateStatusFromCache()\n                    if chunk._status.status == Status.SUCCESS:\n                        nodesToUpdate.add(chunk.node)\n            elif _type == \"node\":\n                node = _item\n                if fileModTime != node.nodeStatusFileLastModTime:\n                    node.updateStatusFromCache()\n                    # Check for success\n                    if node.getGlobalStatus() == Status.SUCCESS:\n                        nodesToUpdate.add(node)\n                    elif node._chunksCreated:\n                        # Chunks have been created -> set the watched files again\n                        self.setWatchedFiles()\n        for node in nodesToUpdate:\n            node.loadOutputAttr()\n\n    def onFilePollerRefreshUpdated(self):\n        \"\"\"\n        Upon an update of the file poller status, retrigger the generation of the list of status files for\n        the chunks that are to be watched.\n        In auto-refresh mode, this includes all the chunks' status files.\n        In minimal auto-refresh mode, this includes only the chunks that are submitted or running.\n        \"\"\"\n        if self.filePollerRefresh is not PollerRefreshStatus.DISABLED.value:\n            self.setWatchedFiles()\n\n    def onComputeStatusChanged(self):\n        \"\"\"\n        When a chunk's status is updated, update the list of watched files with submitted and running chunks if the\n        file poller status is minimal auto-refresh.\n        \"\"\"\n        if self.filePollerRefresh is PollerRefreshStatus.MINIMAL_ENABLED.value:\n            self.setWatchedFiles()\n\n    filePollerRefreshChanged = Signal(int)\n    filePollerRefresh = Property(int, lambda self: self._filesTimePoller.filePollerRefresh, notify=filePollerRefreshChanged)\n\n\nclass GraphLayout(QObject):\n    \"\"\"\n    GraphLayout provides auto-layout features to a UIGraph.\n    \"\"\"\n\n    class DepthMode(Enum):\n        \"\"\" Defines available node depth mode to layout the graph automatically. \"\"\"\n        MinDepth = 0  # use node minimal depth\n        MaxDepth = 1  # use node maximal depth\n\n    # map between DepthMode and corresponding node depth attribute name\n    _depthAttribute = {\n        DepthMode.MinDepth: 'minDepth',\n        DepthMode.MaxDepth: 'depth'\n    }\n\n    def __init__(self, graph):\n        super().__init__(graph)\n        self.graph = graph\n        self._depthMode = GraphLayout.DepthMode.MaxDepth\n        self._nodeWidth = 160  # implicit node width\n        self._nodeHeight = 120   # implicit node height\n        self._gridSpacing = 40  # column/line spacing between nodes\n\n    @Slot(Node, Node, int, int)\n    def autoLayout(self, fromNode=None, toNode=None, startX=0, startY=0):\n        \"\"\"\n        Perform auto-layout from 'fromNode' to 'toNode', starting from (startX, startY) position.\n\n        Args:\n            fromNode (BaseNode): where to start the auto layout from\n            toNode (BaseNode): up to where to perform the layout\n            startX (int): start position x coordinate\n            startY (int): start position y coordinate\n        \"\"\"\n        if not self.graph.nodes:\n            return\n        fromIndex = self.graph.nodes.indexOf(fromNode) if fromNode else 0\n        toIndex = self.graph.nodes.indexOf(toNode) if toNode else self.graph.nodes.count - 1\n\n        def getDepth(n):\n            return getattr(n, self._depthAttribute[self._depthMode])\n\n        maxDepth = max([getDepth(n) for n in self.graph.nodes.values()])\n        grid = [[] for _ in range(maxDepth + 1)]\n\n        # Retrieve reference depth from start node\n        zeroDepth = getDepth(self.graph.nodes.at(fromIndex)) if fromIndex > 0 else 0\n        for i in range(fromIndex, toIndex + 1):\n            n = self.graph.nodes.at(i)\n            grid[getDepth(n) - zeroDepth].append(n)\n\n        with self.graph.groupedGraphModification(\"Graph Auto-Layout\"):\n            for x, line in enumerate(grid):\n                for y, node in enumerate(line):\n                    px = startX + x * (self._nodeWidth + self._gridSpacing)\n                    py = startY + y * (self._nodeHeight + self._gridSpacing)\n                    self.graph.moveNode(node, Position(px, py))\n\n    @Slot()\n    def reset(self):\n        \"\"\" Perform auto-layout on the whole graph. \"\"\"\n        self.autoLayout()\n\n    def positionBoundingBox(self, nodes=None):\n        \"\"\"\n        Return bounding box for a set of nodes as (x, y, width, height).\n\n        Args:\n            nodes (list of Node): the list of nodes or the whole graph if None\n\n        Returns:\n            list of int: the resulting bounding box (x, y, width, height)\n        \"\"\"\n        if nodes is None:\n            nodes = self.graph.nodes.values()\n        if not nodes:\n            return [0, 0, 0, 0]\n        first = nodes[0]\n        bbox = [first.x, first.y, first.x, first.y]\n        for n in nodes:\n            bbox[0] = min(bbox[0], n.x)\n            bbox[1] = min(bbox[1], n.y)\n            bbox[2] = max(bbox[2], n.x)\n            bbox[3] = max(bbox[3], n.y)\n\n        bbox[2] -= bbox[0]\n        bbox[3] -= bbox[1]\n        return bbox\n\n    def boundingBox(self, nodes=None):\n        \"\"\"\n        Return bounding box for a set of nodes as (x, y, width, height).\n\n        Args:\n            nodes (list of Node): the list of nodes or the whole graph if None\n\n        Returns:\n            list of int: the resulting bounding box (x, y, width, height)\n        \"\"\"\n        bbox = self.positionBoundingBox(nodes)\n        bbox[2] += self._nodeWidth\n        bbox[3] += self._nodeHeight\n        return bbox\n\n    def setDepthMode(self, mode):\n        \"\"\" Set node depth mode to use. \"\"\"\n        if isinstance(mode, int):\n            mode = GraphLayout.DepthMode(mode)\n        if self._depthMode.value == mode.value:\n            return\n        self._depthMode = mode\n\n    depthModeChanged = Signal()\n    depthMode = Property(int, lambda self: self._depthMode.value, setDepthMode, notify=depthModeChanged)\n    nodeHeightChanged = Signal()\n    nodeHeight = makeProperty(int, \"_nodeHeight\", notify=nodeHeightChanged)\n    nodeWidthChanged = Signal()\n    nodeWidth = makeProperty(int, \"_nodeWidth\", notify=nodeWidthChanged)\n    gridSpacingChanged = Signal()\n    gridSpacing = makeProperty(int, \"_gridSpacing\", notify=gridSpacingChanged)\n\n\nclass UIGraph(QObject):\n    \"\"\"\n    High level wrapper over core.Graph, with additional features dedicated to UI integration.\n\n    UIGraph exposes undoable methods on its graph and computation in a separate thread.\n    It also provides a monitoring of all its computation units (NodeChunks).\n    \"\"\"\n    def __init__(self, undoStack: commands.UndoStack, taskManager: TaskManager, parent: QObject = None):\n        super().__init__(parent)\n        self._undoStack = undoStack\n        self._taskManager = taskManager\n        self._graph: Graph = Graph('', self)\n\n        self._modificationCount = 0\n        self._chunksMonitor: NodeStatusMonitor = NodeStatusMonitor(parent=self)\n        self._computeThread: Thread = Thread()\n        self._computingLocally = self._submitted = False\n        self._sortedDFSChunks: QObjectListModel = QObjectListModel(parent=self)\n        self._layout: GraphLayout = GraphLayout(self)\n        self._selectedNode = None\n        self._selectedChunk = None\n        self._nodeSelection: QItemSelectionModel = QItemSelectionModel(self._graph.nodes, parent=self)\n        self._hoveredNode = None\n\n        self.submitLabel = \"{projectName}\"\n        self.computeStatusChanged.connect(self.updateLockedUndoStack)\n        self.filePollerRefreshChanged.connect(self._chunksMonitor.filePollerRefreshChanged)\n\n    def setGraph(self, g):\n        \"\"\" Set the internal graph. \"\"\"\n        if self._graph:\n            self.stopExecution()\n            # Clear all the locally submitted nodes at once before the graph gets changed, as it will not receive further updates\n            if self._computingLocally:\n                self._graph.clearLocallySubmittedNodes()\n            self.clear()\n        oldGraph = self._graph\n        self._graph = g\n        if oldGraph:\n            oldGraph.deleteLater()\n\n        self._graph.updated.connect(self.onGraphUpdated)\n        self._graph.statusUpdated.connect(self.updateChunkMonitor)\n        self._taskManager.update(self._graph)\n\n        # Update and connect chunks when the graph is set for the first time\n        self.updateChunks()\n\n        # Perform auto-layout if graph does not provide nodes positions\n        if GraphIO.Features.NodesPositions not in self._graph.fileFeatures:\n            self._layout.reset()\n            # Clear undo-stack after layout\n            self._undoStack.clear()\n        else:\n            bbox = self._layout.positionBoundingBox()\n            if bbox[2] == 0 and bbox[3] == 0:\n                self._layout.reset()\n                # Clear undo-stack after layout\n                self._undoStack.clear()\n\n        self._nodeSelection.setModel(self._graph.nodes)\n        self.graphChanged.emit()\n\n    def onGraphUpdated(self):\n        \"\"\" Callback to any kind of attribute modification. \"\"\"\n        # TODO: handle this with a better granularity\n        self.updateChunks()\n\n    def updateChunks(self):\n        dfsNodes = self._graph.dfsOnFinish(None)[0]\n        chunks = []\n        for node in dfsNodes:\n            if node._chunksCreated:\n                nodechunks = node.getChunks()\n                chunks.extend(nodechunks)\n            else:\n                chunks.extend(node.chunkPlaceholder)\n        if self._sortedDFSChunks.objectList() == chunks:\n            # Nothing has changed, return\n            return\n        for chunk in self._sortedDFSChunks:\n            if chunk not in chunks:\n                # Chunk have been already deleted\n                continue\n            chunk.statusChanged.disconnect(self.updateGraphComputingStatus)\n            chunk.statusChanged.disconnect(self._chunksMonitor.onComputeStatusChanged)\n        self._sortedDFSChunks.setObjectList(chunks)\n        for chunk in self._sortedDFSChunks:\n            chunk.statusChanged.connect(self.updateGraphComputingStatus)\n            chunk.statusChanged.connect(self._chunksMonitor.onComputeStatusChanged)\n        # provide ChunkMonitor with the update list of chunks\n        self.updateChunkMonitor()\n        # update graph computing status based on the new list of NodeChunks\n        self.updateGraphComputingStatus()\n\n    def updateChunkMonitor(self):\n        \"\"\" Update the list of chunks for status files monitoring. \"\"\"\n        nodes = set()\n        for node in self._graph.dfsOnFinish(None)[0]:\n            if not node._chunksCreated:\n                nodes.add(node)\n        for chunk in self._sortedDFSChunks:\n            nodes.add(chunk.node)\n        self._chunksMonitor.setMonitored(list(nodes))\n\n    def clear(self):\n        if self._graph:\n            self.clearNodeHover()\n            self.clearNodeSelection()\n            self._taskManager.clear()\n            self._graph.clear()\n        self._sortedDFSChunks.clear()\n        self._undoStack.clear()\n\n    def stopChildThreads(self):\n        \"\"\" Stop all child threads. \"\"\"\n        self.stopExecution()\n        self._chunksMonitor.stop()\n\n    @Slot(str)\n    def loadGraph(self, filepath):\n        g = Graph(\"\")\n        if filepath:\n            g.load(filepath)\n            if not os.path.exists(g.cacheDir):\n                os.mkdir(g.cacheDir)\n        self.setGraph(g)\n\n    @Slot(str)\n    @Slot(str, bool)\n    def initFromTemplate(self, filepath, copyOutputs=False):\n        graph = Graph(\"\")\n        if filepath:\n            graph.initFromTemplate(filepath, copyOutputs=copyOutputs)\n        self.setGraph(graph)\n\n    @Slot(QUrl, result=\"QVariantList\")\n    @Slot(QUrl, QPoint, result=\"QVariantList\")\n    def importProject(self, filepath, position=None):\n        if isinstance(filepath, (QUrl)):\n            # depending how the QUrl has been initialized,\n            # toLocalFile() may return the local path or an empty string\n            localFile = filepath.toLocalFile()\n            if not localFile:\n                localFile = filepath.toString()\n        else:\n            localFile = filepath\n        if isinstance(position, QPoint):\n                position = Position(position.x(), position.y())\n        yOffset = self.layout.gridSpacing + self.layout.nodeHeight\n        return self.push(commands.ImportProjectCommand(self._graph, localFile, position=position, yOffset=yOffset))\n\n    @Slot(QUrl)\n    def saveAs(self, url):\n        self._saveAs(url)\n\n    @Slot(QUrl)\n    def saveAsTemplate(self, url):\n        self._saveAs(url, setupProjectFile=False, template=True)\n\n    def _saveAs(self, url, setupProjectFile=True, template=False):\n        \"\"\" Helper function for 'save as' features. \"\"\"\n        if isinstance(url, (str)):\n            localFile = url\n        else:\n            localFile = url.toLocalFile()\n        # ensure file is saved with \".mg\" extension\n        if os.path.splitext(localFile)[-1] != \".mg\":\n            localFile += \".mg\"\n        self._graph.save(localFile, setupProjectFile=setupProjectFile, template=template)\n        self._undoStack.setClean()\n        # saving file on disk impacts cache folder location\n        # => force re-evaluation of monitored status files paths\n        self.updateChunkMonitor()\n\n    @Slot()\n    def saveAsTemp(self):\n        projectPath = generateTempProjectFilepath()\n        self._saveAs(projectPath)\n\n    @Slot()\n    def save(self):\n        self._graph.save()\n        self._undoStack.setClean()\n\n    @Slot()\n    def saveAsNewVersion(self):\n        self._graph.saveAsNewVersion()\n        self._undoStack.setClean()\n\n    @Slot()\n    def updateLockedUndoStack(self):\n        if self.isComputingLocally():\n            self._undoStack.lockAtThisIndex()\n        else:\n            self._undoStack.unlock()\n\n    @Slot()\n    @Slot(Node)\n    @Slot(list)\n    def execute(self, nodes: Optional[Union[list[Node], Node]] = None):\n        nodes = [nodes] if not isinstance(nodes, Iterable) and nodes else nodes\n        self.save()  # always save the graph before computing\n        self._taskManager.compute(self._graph, nodes)\n        self.updateLockedUndoStack()  # explicitly call the update while it is already computing\n\n    @Slot()\n    def stopExecution(self):\n        self.updateChunks()\n        if not self.isComputingLocally():\n            return\n        self._taskManager.requestBlockRestart()\n        self._graph.stopExecution()\n        self._taskManager.join()\n\n    @Slot(Node)\n    def stopNodeComputation(self, node):\n        \"\"\" Stop the computation of the node and update all the nodes depending on it. \"\"\"\n        self.updateChunks()\n        if not self.isComputingLocally():\n            return\n\n        # Stop the node and wait Task Manager\n        node.stopComputation()\n        self._taskManager.join()\n\n    @Slot(Node)\n    def cancelNodeComputation(self, node):\n        \"\"\" Cancel the computation of the node and all the nodes depending on it. \"\"\"\n        self.updateChunks()\n        if node.getGlobalStatus() == Status.SUBMITTED:\n            # Status from SUBMITTED to NONE\n            # Make sure to remove the nodes from the Task Manager list\n            node.clearSubmittedChunks()\n            self._taskManager.removeNode(node, displayList=True, processList=True)\n\n            for n in node.getOutputNodes(recursive=True, dependenciesOnly=True):\n                n.clearSubmittedChunks()\n                self._taskManager.removeNode(n, displayList=True, processList=True)\n\n    def isChunkComputingLocally(self, chunk):\n        # Update graph computing status\n        computingLocally = chunk._status.execMode == ExecMode.LOCAL and \\\n                           (sessionUid in (chunk.node._nodeStatus.submitterSessionUid, chunk._status.computeSessionUid)) and \\\n                           (chunk._status.status in (Status.RUNNING, Status.SUBMITTED))\n        return computingLocally\n\n    def isChunkComputingExternally(self, chunk):\n        # Note: We do not check computeSessionUid for the submitted status,\n        #       as the source instance of the submit has no importance.\n        return (chunk._status.execMode == ExecMode.EXTERN) and \\\n                chunk._status.status in (Status.RUNNING, Status.SUBMITTED)\n\n    @Slot(NodeChunk)\n    def stopTask(self, chunk: NodeChunk):\n        \"\"\" Stop the selected task. \"\"\"\n        chunk.updateStatusFromCache()\n        if not chunk.isAlreadySubmitted():\n            return\n        node = chunk.node\n        job = jobManager.getNodeJob(node)\n        if job:\n            chunkIteration = chunk.range.iteration\n            try:\n                job.stopChunkTask(node, chunkIteration)\n            except Exception as e:\n                self.parent().showMessage(f\"Failed to stop chunk {chunkIteration} of {node.label}\", \"error\")\n                logging.warning(f\"Error on stopTask:\\n{e}\")\n            else:\n                chunk.updateStatusFromCache()\n                chunk.upgradeStatusTo(Status.STOPPED)\n                # TODO: Stop depending nodes?\n                self.parent().showMessage(f\"Stopped chunk {chunkIteration} of {node.label}\")\n        else:\n            chunk.stopProcess()\n            self._taskManager._cancelledChunks.append(chunk)\n            for chunk in node._chunks:\n                if chunk._status.status == Status.SUBMITTED:\n                    chunk.stopProcess()\n                    self._taskManager._cancelledChunks.append(chunk)\n            for n in node.getOutputNodes(recursive=True, dependenciesOnly=True):\n                n.clearSubmittedChunks()\n                self._taskManager.removeNode(n, displayList=True, processList=True)\n\n    @Slot(Node)\n    def stopNode(self, node: Node):\n        \"\"\" Stop the selected task. \"\"\"\n        job = jobManager.getNodeJob(node)\n        if job:\n            try:\n                job.stopChunkTask(node, -1)\n            except Exception as e:\n                self.parent().showMessage(f\"Failed to stop node {node.label}\", \"error\")\n                logging.warning(f\"Error on stopTask:\\n{e}\")\n            else:\n                node.updateNodeStatusFromCache()\n                node.upgradeStatusTo(Status.STOPPED)\n                # TODO : Stop depending nodes ?\n                self.parent().showMessage(f\"Stopped node {node.label}\")\n        else:\n            self.cancelNodeComputation(node)\n            node.stopComputation()\n\n    @Slot(NodeChunk)\n    def restartTask(self, chunk: NodeChunk):\n        \"\"\" Relaunch a stopped task. \"\"\"\n        node = chunk.node\n        job = jobManager.getNodeJob(node)\n        if job:\n            chunkIteration = chunk.range.iteration\n            try:\n                chunk.updateStatusFromCache()\n                chunk.upgradeStatusTo(Status.SUBMITTED)\n                job.restartChunkTask(node, chunkIteration)\n            except Exception as e:\n                chunk.updateStatusFromCache()\n                chunk.upgradeStatusTo(Status.ERROR)\n                self.parent().showMessage(f\"Failed to relaunch chunk {chunkIteration} of {node.label}\", \"error\")\n                logging.warning(f\"Error on restartTask:\\n{e}\")\n            else:\n                self.parent().showMessage(f\"Relaunched chunk {chunkIteration} of {node.label}\")\n        else:\n            # For this we would need to use a pool (with either chunks or nodes)\n            # instead of the list of nodes that are processed serially\n            self.parent().showMessage(f\"Chunks cannot be launched individually locally\", \"warning\")\n            if self.canComputeNode(node):\n                self.execute([node])\n\n    @Slot(NodeChunk)\n    def skipTask(self, chunk: NodeChunk):\n        \"\"\"\n        Skip the task: the job will continue as if the task succeeded.\n        In local mode, the chunk status will be set to success.\n        \"\"\"\n        chunk.updateStatusFromCache()\n        node = chunk.node\n        chunkIteration = chunk.range.iteration\n        job = jobManager.getNodeJob(node)\n        if job:\n            try:\n                job.skipChunkTask(node, chunkIteration)\n            except Exception as e:\n                self.parent().showMessage(f\"Failed to skip chunk {chunkIteration} of {node.label}\", \"error\")\n                logging.warning(f\"Error on skipTask:\\n{e}\")\n            else:\n                chunk.upgradeStatusTo(Status.SUCCESS)\n                self.parent().showMessage(f\"Skipped chunk {chunkIteration} of {node.label}\")\n        else:\n            chunk.stopProcess()\n            chunk.upgradeStatusTo(Status.SUCCESS)\n            self._taskManager._cancelledChunks.append(chunk)\n            self.parent().showMessage(f\"Skipped chunk {chunkIteration} of {node.label}\")\n\n    @Slot(Node)\n    def pauseJob(self, node: Node):\n        \"\"\"\n        Pause the running job : cancel all scheduled tasks.\n        Current task is not stopped but future tasks will not be launched.\n        \"\"\"\n        job = jobManager.getNodeJob(node)\n        if job:\n            try:\n                job.pauseJob()\n            except Exception as e:\n                logging.warning(f\"Error on pauseJob:\\n{e}\")\n                self.parent().showMessage(f\"Failed to pause the job for node {node}\", \"error\")\n            else:\n                self.parent().showMessage(f\"Paused node {node.label} on farm\")\n        elif not node.isExtern():\n            self.parent().showMessage(f\"PauseJob is only available in external computation mode!\", \"warning\")\n        else:\n            self.parent().showMessage(f\"Cannot retrieve the job\", \"error\")\n\n    @Slot(Node)\n    def resumeJob(self, node: Node):\n        \"\"\" Resume the paused job. \"\"\"\n        job = jobManager.getNodeJob(node)\n        if job:\n            # Node is submitted to farm\n            try:\n                job.resumeJob()\n            except Exception as e:\n                self.parent().showMessage(f\"Failed to resume node {node.label} on farm\")\n                logging.warning(f\"Error on resumeJob:\\n{e}\")\n            else:\n                self.parent().showMessage(f\"Resumed the job for node {node}\")\n        else:\n            # In this case user can just relaunch the node computation\n            # Could be implemented if we had a paused state on the task manager\n            # Where unprocessed nodes are retained\n            pass\n\n    @Slot(Node)\n    def interruptJob(self, node: Node):\n        \"\"\" Interrupt the job that processes the node. \"\"\"\n        job = jobManager.getNodeJob(node)\n        if job:\n            try:\n                job.interruptJob()\n            except Exception as e:\n                self.parent().showMessage(f\"Failed to interrupt node {node.label} on farm\", \"error\")\n                logging.warning(f\"Error on interruptJob:\\n{e}\")\n            else:\n                for chunk in self._sortedDFSChunks:\n                    if jobManager.getNodeJob(chunk.node) == job:\n                        if chunk._status.status in (Status.SUBMITTED, Status.RUNNING):\n                            chunk.updateStatusFromCache()\n                            chunk.upgradeStatusTo(Status.STOPPED)\n                for _node in self._graph.dfsOnFinish(None)[0]:\n                    if jobManager.getNodeJob(_node) == job and not _node._chunksCreated and \\\n                        _node._nodeStatus.status in (Status.SUBMITTED, Status.RUNNING):\n                        _node.upgradeStatusTo(Status.STOPPED)\n                self.parent().showMessage(f\"Interrupted the job for node {node}\")\n        elif not node.isExtern():\n            for chunk in self._sortedDFSChunks:\n                if not chunk.isExtern() and chunk._status.status in (Status.SUBMITTED, Status.RUNNING):\n                    chunk.updateStatusFromCache()\n                    chunk.upgradeStatusTo(Status.STOPPED)\n            for node in self._graph.dfsOnFinish(None)[0]:\n                if not node.isExtern() and not node._chunksCreated and \\\n                    node._nodeStatus.status in (Status.SUBMITTED, Status.RUNNING):\n                    node.upgradeStatusTo(Status.STOPPED)\n            self.stopExecution()\n            self.parent().showMessage(f\"Stopped the local job process\")\n        else:\n            self.parent().showMessage(f\"Could not retrieve job for node {node}\", \"error\")\n\n    @Slot(Node)\n    def restartJobErrorTasks(self, node: Node):\n        \"\"\" Restart all tasks in the job that have failed. \"\"\"\n        job = jobManager.getNodeJob(node)\n        if job:\n            try:\n                # Fist update status of each chunk to submitted\n                for chunk in self._sortedDFSChunks:\n                    if chunk._status.status not in (Status.ERROR, Status.STOPPED, Status.KILLED):\n                        continue\n                    if jobManager.getNodeJob(chunk.node) == job:\n                        chunk.upgradeStatusTo(Status.SUBMITTED)\n                for node in self._graph.dfsOnFinish(None)[0]:\n                    if not node._chunksCreated and node._nodeStatus.status in (Status.ERROR, Status.STOPPED, Status.KILLED):\n                        node.upgradeStatusTo(Status.SUBMITTED)\n                job.restartErrorTasks()\n                job.resumeJob()\n            except Exception as e:\n                self.parent().showMessage(f\"Failed to restart error tasks for node {node.label} on farm\", \"error\")\n                logging.warning(f\"Error on restartJobErrorTasks:\\n{e}\")\n            else:\n                self.parent().showMessage(f\"Restarted error tasks for the node {node}\")\n        else:\n            # In this case user can just relaunch the node computation\n            # Could be implemented if we had a paused state on the task manager\n            # Where error/failed nodes are retained\n            pass\n\n    @Slot()\n    @Slot(Node)\n    @Slot(list)\n    def submit(self, nodes: Optional[Union[list[Node], Node]] = None):\n        \"\"\"\n        Submit the graph to the default Submitter.\n        If a node is specified, submit this node and its uncomputed predecessors.\n        Otherwise, submit the whole\n\n        Notes:\n            Default submitter is specified using the MESHROOM_DEFAULT_SUBMITTER environment variable.\n        \"\"\"\n        self.save()  # graph must be saved before being submitted\n        self._undoStack.clear()  # the undo stack must be cleared\n        nodes = [nodes] if not isinstance(nodes, Iterable) and nodes else nodes\n        mrDefaultSubmitter = os.environ.get('MESHROOM_DEFAULT_SUBMITTER', '')\n        chosenSubmitter = self.parent()._defaultSubmitterName or mrDefaultSubmitter\n        self.parent().showMessage(f\"Submit job on farm through {chosenSubmitter}\")\n        self.parent().showMessage(f\"Nodes to submit : {nodes}\")\n        self._taskManager.submit(self._graph, chosenSubmitter, nodes, submitLabel=self.submitLabel)\n\n    def updateGraphComputingStatus(self):\n        dfsNodes = self._graph.dfsOnFinish(None)[0]\n        # TODO : these functions should go on the node part\n        # We should do any([node.isRunning for node in dfsNodes])\n\n        # update graph computing status\n        computingLocally = any([\n            ch._status.execMode == ExecMode.LOCAL and \\\n            (sessionUid in (ch.node._nodeStatus.submitterSessionUid, ch._status.computeSessionUid)) and \\\n            (ch._status.status in (Status.RUNNING, Status.SUBMITTED))\n            for ch in self._sortedDFSChunks\n        ])\n        # Note: We do not check computeSessionUid for the submitted status,\n        #       as the source instance of the submit has no importance.\n        submitted = any([ch._status.execMode == ExecMode.EXTERN and ch._status.status in (Status.RUNNING, Status.SUBMITTED) for ch in self._sortedDFSChunks])\n\n        # Handle nodes with uninitialized chunks\n        for node in dfsNodes:\n            if node._chunksCreated:\n                continue\n            if node._nodeStatus.status in (Status.RUNNING, Status.SUBMITTED):\n                # TODO : save session ID in node\n                if node._nodeStatus.execMode == ExecMode.LOCAL:\n                    computingLocally = True\n                elif node._nodeStatus.execMode == ExecMode.EXTERN:\n                    submitted = True\n\n        if self._computingLocally != computingLocally or self._submitted != submitted:\n            self._computingLocally = computingLocally\n            self._submitted = submitted\n            self.computeStatusChanged.emit()\n\n    def isComputing(self):\n        \"\"\" Whether is graph is being computed, either locally or externally. \"\"\"\n        return self.isComputingLocally() or self.isComputingExternally()\n\n    def isComputingExternally(self):\n        \"\"\" Whether this graph is being computed externally. \"\"\"\n        return self._submitted\n\n    def isComputingLocally(self):\n        \"\"\" Whether this graph is being computed locally (i.e computation can be stopped). \"\"\"\n        ## One solution could be to check if the thread is still running,\n        # but the latency in creating/stopping the thread can be off regarding the update signals.\n        # isRunningThread = self._taskManager._thread.isRunning()\n        ## Another solution is to retrieve the current status directly from all chunks status\n        # isRunning = self._taskManager.hasRunningChunks()\n        ## For performance reason, we use a precomputed value updated in updateGraphComputingStatus:\n        return self._computingLocally\n\n    def push(self, command):\n        \"\"\"\n        Try and push the given command to the undo stack.\n\n        Args:\n            command (commands.UndoCommand): the command to push\n        \"\"\"\n        return self._undoStack.tryAndPush(command)\n\n    def groupedGraphModification(self, title, disableUpdates=True):\n        \"\"\"\n        Get a GroupedGraphModification for this Graph.\n\n        Args:\n            title (str): the title of the macro command\n            disableUpdates (bool): whether to disable graph updates\n\n        Returns:\n            GroupedGraphModification: the instantiated context manager\n        \"\"\"\n        return commands.GroupedGraphModification(self._graph, self._undoStack, title, disableUpdates)\n\n    @Slot(str)\n    def beginModification(self, name):\n        \"\"\"\n        Begin a Graph modification.\n        Calls to beginModification and endModification may be nested, but\n        every call to beginModification must have a matching call to endModification.\n        \"\"\"\n        self._modificationCount += 1\n        self._undoStack.beginMacro(name)\n\n    @Slot()\n    def endModification(self):\n        \"\"\" Ends a Graph modification. Must match a call to beginModification. \"\"\"\n        assert self._modificationCount > 0\n        self._modificationCount -= 1\n        self._undoStack.endMacro()\n\n    @Slot(str, QPoint, result=QObject)\n    def addNewNode(self, nodeType, position=None, **kwargs):\n        \"\"\" [Undoable]\n        Create a new Node of type 'nodeType' and returns it.\n\n        Args:\n            nodeType (str): the type of the Node to create.\n            position (QPoint): (optional) the initial position of the node\n            **kwargs: optional node attributes values\n\n        Returns:\n            Node: the created node\n        \"\"\"\n        if isinstance(position, QPoint):\n            position = Position(position.x(), position.y())\n        return self.push(commands.AddNodeCommand(self._graph, nodeType, position=position, **kwargs))\n\n    @Slot(Node, str, result=str)\n    def renameNode(self, node: Node, newName: str):\n        \"\"\" Triggers the node renaming.\n\n        In this function the last `_N` index is removed, then all special characters\n        (everything except letters and numbers) are removed.\n        The name uniqueness will be ensured later by adding a suffix (e.g. `_1`, `_2`, ...)\n\n        Labels can be used to have special characters in the displayed name.\n\n        Args:\n            node (Node): Node to rename.\n            newName (str): New name to set.\n\n        Returns:\n            str: The final name of the node.\n        \"\"\"\n        newName = \"_\".join(newName.split(\"_\")[:-1]) if \"_\" in newName else newName\n        # Eliminate all characters except digits and letters\n        newName = re.sub(r\"[^0-9a-zA-Z]\", \"\", newName)\n        # Create unique name\n        uniqueName = self._graph._createUniqueNodeName(newName, {n._name for n in self._graph._nodes if n != node})\n        if not newName or uniqueName == node._name:\n            return \"\"\n        return self.push(commands.RenameNodeCommand(self._graph, node, uniqueName))\n\n    def moveNode(self, node: Node, position: Position):\n        \"\"\"\n        Move `node` to the given `position`.\n\n        Args:\n            node: The node to move.\n            position: The target position.\n        \"\"\"\n        self.push(commands.MoveNodeCommand(self._graph, node, position))\n\n    @Slot(BackdropNode, int, int)\n    def resizeNode(self, node, width, height):\n        \"\"\"\n        Resize `node` to the given `width` and `height`.\n\n        Args:\n            node: The node to resize.\n            width: The target width.\n            height: The target height.\n        \"\"\"\n        with self.groupedGraphModification(\"Resize Node\"):\n            if node.hasInternalAttribute(\"nodeWidth\"):\n                self.setAttribute(node.internalAttribute(\"nodeWidth\"), width)\n            if node.hasInternalAttribute(\"nodeHeight\"):\n                self.setAttribute(node.internalAttribute(\"nodeHeight\"), height)\n\n    @Slot(BackdropNode, int, int, QPoint)\n    def resizeAndMoveNode(self, node, width, height, position=None):\n        \"\"\"\n        Resize `node` to the given `width` and `height`, and move it to the given `position`.\n\n        Args:\n            node: The node to resize and move.\n            width: The target width.\n            height: The target height.\n            position: The target position.\n        \"\"\"\n        with self.groupedGraphModification(\"Resize and Move Node\"):\n            if node.hasInternalAttribute(\"nodeWidth\"):\n                self.setAttribute(node.internalAttribute(\"nodeWidth\"), width)\n            if node.hasInternalAttribute(\"nodeHeight\"):\n                self.setAttribute(node.internalAttribute(\"nodeHeight\"), height)\n\n            if position:\n                self.moveNode(node, Position(position.x(), position.y()))\n\n    @Slot(QPoint, int, int, result=QObject)\n    def addBackdropNode(self, position, width, height):\n        \"\"\"[Undoable]\n        Create a new Backdrop Node at the given position with the given dimensions as a single undo entry.\n\n        Args:\n            position (QPoint): the position of the backdrop node.\n            width (int): the width of the backdrop node.\n            height (int): the height of the backdrop node.\n\n        Returns:\n            BackdropNode: the created node.\n        \"\"\"\n        with self.groupedGraphModification(\"Add Backdrop\"):\n            node = self.addNewNode(\"Backdrop\", position)\n            if node.hasInternalAttribute(\"nodeWidth\"):\n                self.setAttribute(node.internalAttribute(\"nodeWidth\"), width)\n            if node.hasInternalAttribute(\"nodeHeight\"):\n                self.setAttribute(node.internalAttribute(\"nodeHeight\"), height)\n        return node\n\n    @Slot(QPoint)\n    def moveSelectedNodesBy(self, offset: QPoint):\n        \"\"\" Move all the selected nodes by the given `offset`. \"\"\"\n\n        with self.groupedGraphModification(\"Move Selected Nodes\"):\n            for node in self.iterSelectedNodes():\n                position = Position(node.x + offset.x(), node.y + offset.y())\n                self.moveNode(node, position)\n\n    def getMeanPosition(self):\n        \"\"\" Get the average Position of selected nodes. \"\"\"\n        # Not great, would be better if Position was a non-tuple class\n        selectedNodes = self.getSelectedNodes()\n        sum_pose = [0, 0]\n        nb_tot = 0\n        for selectedNode in selectedNodes:\n            sum_pose[0] += selectedNode.x\n            sum_pose[1] += selectedNode.y\n            nb_tot += 1\n        return Position(int(sum_pose[0] / nb_tot), int(sum_pose[1] / nb_tot))\n\n    @Slot()\n    def alignHorizontally(self):\n        \"\"\" All nodes are moved horizontally to the same position, on an average position of selected nodes. \"\"\"\n        nodePadding = 50\n        selectedNodes = self.getSelectedNodes()\n        if len(selectedNodes) < 2:\n            return\n\n        # Make sure the list is correctly ordered\n        selectedNodes = sorted(selectedNodes, key=lambda node:node.x)\n\n        meanX, meanY = self.getMeanPosition()\n        nodeWidth = self.layout.nodeWidth\n        # Compute the first node X position\n        totalWidth = len(selectedNodes) * nodeWidth + (len(selectedNodes) - 1) * nodePadding\n        startX = int(meanX - totalWidth / 2 + nodeWidth / 2)\n        with self.groupedGraphModification(\"Align nodes horizontally\"):\n            for i, selectedNode in enumerate(selectedNodes):\n                x = startX + i * (nodeWidth + nodePadding)\n                self.moveNode(selectedNode, Position(x, meanY))\n\n    @Slot()\n    def alignVertically(self):\n        \"\"\" All nodes are moved vertically to the same position, on an average position of selected nodes. \"\"\"\n        selectedNodes = self.getSelectedNodes()\n        if len(selectedNodes) < 2:\n            return\n\n        meanX, _ = self.getMeanPosition()\n        with self.groupedGraphModification(\"Align nodes vertically\"):\n            for selectedNode in selectedNodes:\n                self.moveNode(selectedNode, Position(meanX, selectedNode.y))\n\n    @Slot(list)\n    def removeNodes(self, nodes: list[Node]):\n        \"\"\"\n        Remove 'nodes' from the graph.\n\n        Args:\n            nodes: The nodes to remove.\n        \"\"\"\n        if any(n.locked for n in nodes):\n            return\n\n        with self.groupedGraphModification(\"Remove Nodes\"):\n            for node in nodes:\n                self.push(commands.RemoveNodeCommand(self._graph, node))\n\n    @Slot()\n    def removeSelectedNodes(self):\n        \"\"\" Remove selected nodes from the graph. \"\"\"\n        self.removeNodes(list(self.iterSelectedNodes()))\n\n    @Slot(list)\n    def removeNodesFrom(self, nodes: list[Node]):\n        \"\"\"\n        Remove all nodes starting from 'nodes' to graph leaves.\n\n        Args:\n            nodes: the nodes to start from.\n        \"\"\"\n        with self.groupedGraphModification(\"Remove Nodes From Selected Nodes\"):\n            nodesToRemove, _ = self._graph.dfsOnDiscover(startNodes=nodes, reverse=True, dependenciesOnly=True)\n            # Filter out nodes that will be removed more than once\n            uniqueNodesToRemove = list(dict.fromkeys(nodesToRemove))\n            # Perform nodes removal from leaves to start node so that edges can be re-created in correct order on redo\n            self.removeNodes(list(reversed(uniqueNodesToRemove)))\n\n    @Slot(list, result=list)\n    def duplicateNodes(self, nodes: list[Node]) -> list[Node]:\n        \"\"\"\n        Duplicate 'nodes'.\n\n        Args:\n            nodes: the nodes to duplicate.\n\n        Returns:\n            The list of duplicated nodes.\n        \"\"\"\n        nPositions = [(n.x, n.y) for n in self._graph.nodes]\n        # Enable updates between duplication and layout to get correct depths during layout\n        with self.groupedGraphModification(\"Duplicate Selected Nodes\", disableUpdates=False):\n            # Disable graph updates during duplication\n            with self.groupedGraphModification(\"Node duplication\", disableUpdates=True):\n                duplicates = self.push(commands.DuplicateNodesCommand(self._graph, nodes))\n            # Move nodes below the bounding box formed by the duplicated node(s)\n            bbox = self._layout.boundingBox(nodes)\n\n            for n in duplicates:\n                yPos = n.y + self.layout.gridSpacing + bbox[3]\n                if (n.x, yPos) in nPositions:\n                    # Make sure the node will not be moved on top of another node\n                    while (n.x, yPos) in nPositions:\n                        yPos = yPos + self.layout.gridSpacing + self.layout.nodeHeight\n                    self.moveNode(n, Position(n.x, yPos))\n                else:\n                    self.moveNode(n, Position(n.x, bbox[3] + self.layout.gridSpacing + n.y))\n                nPositions.append((n.x, n.y))\n\n        return duplicates\n\n    @Slot(list, result=list)\n    def duplicateNodesFrom(self, nodes: list[Node]) -> list[Node]:\n        \"\"\"\n        Duplicate all nodes starting from 'nodes' to graph leaves.\n\n        Args:\n            node: The nodes to start from.\n        Returns:\n            The list of duplicated nodes.\n        \"\"\"\n        with self.groupedGraphModification(\"Duplicate Nodes From Selected Nodes\"):\n            nodesToDuplicate, _ = self._graph.dfsOnDiscover(startNodes=nodes, reverse=True, dependenciesOnly=True)\n            # Filter out nodes that will be duplicated more than once\n            uniqueNodesToDuplicate = list(dict.fromkeys(nodesToDuplicate))\n            duplicates = self.duplicateNodes(uniqueNodesToDuplicate)\n        return duplicates\n\n    @Slot(Edge, result=bool)\n    def canExpandForLoop(self, currentEdge):\n        \"\"\" Check if the list attribute can be expanded by looking at all the edges connected to it. \"\"\"\n        listAttribute = currentEdge.src.root\n        # Check that the parent is indeed a ListAttribute (it could be a GroupAttribute, for example)\n        if not listAttribute or not isinstance(listAttribute, ListAttribute):\n            return False\n        srcIndex = listAttribute.index(currentEdge.src)\n        allSrc = [e.src for e in self._graph.edges.values()]\n        for i in range(len(listAttribute)):\n            if i == srcIndex:\n                continue\n            if listAttribute.at(i) in allSrc:\n                return False\n        return True\n\n    @Slot(Edge, result=Edge)\n    def expandForLoop(self, currentEdge):\n        \"\"\" Expand 'node' by creating all its output nodes. \"\"\"\n        with self.groupedGraphModification(\"Expand For Loop Node\"):\n            listAttribute = currentEdge.src.root\n            dst = currentEdge.dst\n\n            for i in range(1, len(listAttribute)):\n                duplicates = self.duplicateNodesFrom([dst.node])\n                newNode = duplicates[0]\n                previousEdge = self.graph.edge(newNode.attribute(dst.name))\n                self.replaceEdge(previousEdge, listAttribute.at(i), previousEdge.dst)\n\n            # Last, replace the edge with the first element of the list\n            return self.replaceEdge(currentEdge, listAttribute.at(0), dst)\n\n    @Slot(Edge)\n    def collapseForLoop(self, currentEdge):\n        \"\"\" Collapse 'node' by removing all its output nodes. \"\"\"\n        with self.groupedGraphModification(\"Collapse For Loop Node\"):\n            listAttribute = currentEdge.src.root\n            srcIndex = listAttribute.index(currentEdge.src)\n            allSrc = [e.src for e in self._graph.edges.values()]\n            for i in reversed(range(len(listAttribute))):\n                if i == srcIndex:\n                    continue\n                occurrence = allSrc.index(listAttribute.at(i)) if listAttribute.at(i) in allSrc else -1\n                if occurrence != -1:\n                    self.removeNodesFrom([self.graph.edges.at(occurrence).dst.node])\n                    # update the edges from allSrc\n                    allSrc = [e.src for e in self._graph.edges.values()]\n\n    @Slot()\n    def clearSelectedNodesData(self):\n        \"\"\" Clear data from all selected nodes. \"\"\"\n        self.clearData(self.iterSelectedNodes())\n\n    @Slot(list)\n    def clearData(self, nodes: list[Node]):\n        \"\"\" Clear data from 'nodes'. \"\"\"\n        for n in nodes:\n            n.clearData()\n\n    @Slot(list)\n    def clearDataFrom(self, nodes: list[Node]):\n        \"\"\"\n        Clear data from all nodes starting from 'nodes' to graph leaves.\n\n        Args:\n            nodes: The nodes to start from.\n        \"\"\"\n        self.clearData(self._graph.dfsOnDiscover(startNodes=nodes, reverse=True, dependenciesOnly=True)[0])\n\n    @Slot(Attribute, Attribute)\n    def addEdge(self, src, dst):\n        if isinstance(src, ListAttribute) and not isinstance(dst, ListAttribute):\n            self._addEdge(src.at(0), dst)\n        elif isinstance(dst, ListAttribute) and not isinstance(src, ListAttribute):\n            with self.groupedGraphModification(f\"Insert and Add Edge on {dst.fullName}\"):\n                self.appendAttribute(dst)\n                self._addEdge(src, dst.at(-1))\n        else:\n            self._addEdge(src, dst)\n\n    def _addEdge(self, src, dst):\n        with self.groupedGraphModification(f\"Connect '{src.fullName}'->'{dst.fullName}'\"):\n            if dst in self._graph.edges.keys():\n                self.removeEdge(self._graph.edge(dst))\n            self.push(commands.AddEdgeCommand(self._graph, src, dst))\n\n    @Slot(Edge)\n    def removeEdge(self, edge):\n        with self.groupedGraphModification(f\"Remove Edge and Delete {edge.dst.fullName}\"):\n            if isinstance(edge.dst.root, ListAttribute):\n                self.push(commands.RemoveEdgeCommand(self._graph, edge))\n                self.removeAttribute(edge.dst)\n                return\n            self.push(commands.RemoveEdgeCommand(self._graph, edge))\n\n    @Slot(list)\n    def deleteEdgesByIndices(self, indices):\n        with self.groupedGraphModification(\"Remove Edges\"):\n            copied = list(self._graph.edges)\n            for index in indices:\n                self.removeEdge(copied[index])\n\n    @Slot()\n    def disconnectSelectedNodes(self):\n        with self.groupedGraphModification(\"Disconnect Nodes\"):\n            selectedNodes = self.getSelectedNodes()\n            for edge in self._graph.edges[:]:\n                # Remove only the edges which are coming or going out of the current selection\n                if edge.src.node in selectedNodes and edge.dst.node in selectedNodes:\n                    continue\n\n                if edge.dst.node in selectedNodes or edge.src.node in selectedNodes:\n                    self.removeEdge(edge)\n\n    @Slot(Edge, Attribute, Attribute, result=Edge)\n    def replaceEdge(self, edge, newSrc, newDst):\n        with self.groupedGraphModification(f\"Replace Edge '{edge.src.fullName}'->'{edge.dst.fullName}' with '{newSrc.fullName}'->'{newDst.fullName}'\"):\n            self.removeEdge(edge)\n            self.addEdge(newSrc, newDst)\n        return self._graph.edge(newDst)\n\n    @Slot(Attribute, result=Edge)\n    def getEdge(self, dst):\n        return self._graph.edge(dst)\n\n    @Slot(Attribute, \"QVariant\")\n    def setAttribute(self, attribute, value):\n        self.push(commands.SetAttributeCommand(self._graph, attribute, value))\n\n    @Slot(Attribute)\n    def resetAttribute(self, attribute):\n        \"\"\" Reset 'attribute' to its default value. \"\"\"\n        with self.groupedGraphModification(f\"Reset Attribute '{attribute.name}'\"):\n            # if the attribute is a ListAttribute, remove all edges\n            if isinstance(attribute, ListAttribute):\n                for edge in self._graph.edges:\n                    # if the edge is connected to one of the ListAttribute's elements, remove it\n                    if edge.src in attribute.value:\n                        self.removeEdge(edge)\n            self.push(commands.SetAttributeCommand(self._graph, attribute, attribute.getDefaultValue()))\n\n    @Slot(Attribute, str, \"QVariant\")\n    def addAttributeKeyValue(self, attribute, key, value):\n        \"\"\" Add the given (key, value) pair to the given keyable attribute. \"\"\"\n        self.push(commands.AddAttributeKeyValueCommand(self._graph, attribute, key, value))\n\n    @Slot(Attribute, str)\n    def addAttributeKeyDefaultValue(self, attribute, key):\n        \"\"\" Add the given key with the default value to the given keyable attribute. \"\"\"\n        self.push(commands.AddAttributeKeyValueCommand(self._graph, attribute, key, attribute.getDefaultValue()))\n\n    @Slot(Attribute, str)\n    def removeAttributeKey(self, attribute, key):\n        \"\"\" Remove the given key from the given keyable attribute. \"\"\"\n        self.push(commands.RemoveAttributeKeyCommand(self._graph, attribute, key))\n\n    @Slot(str, str, \"QVariant\")\n    def setObservationFromName(self, shapeFullName, key, observation):\n        \"\"\" Set the given observation for the given shape attribute name. \"\"\"\n        shape = self.graph.attribute(shapeFullName)\n        if shape is None:\n            shape = self.graph.internalAttribute(shapeFullName)\n        self.push(commands.SetObservationCommand(self._graph, shape, key, observation))\n\n    @Slot(ShapeAttribute, str, \"QVariant\")\n    def setObservation(self, shape, key, observation):\n        \"\"\" Set the given observation for the given shape attribute. \"\"\"\n        self.push(commands.SetObservationCommand(self._graph, shape, key, observation))\n\n    @Slot(ShapeAttribute, str)\n    def removeObservation(self, shape, key):\n        \"\"\" Remove the given observation for the given shape attribute. \"\"\"\n        self.push(commands.RemoveObservationCommand(self._graph, shape, key))\n\n    @Slot(CompatibilityNode, result=Node)\n    def upgradeNode(self, node):\n        \"\"\" Upgrade a CompatibilityNode. \"\"\"\n        return self.push(commands.UpgradeNodeCommand(self._graph, node))\n\n    @Slot()\n    def upgradeAllNodes(self):\n        \"\"\" Upgrade all upgradable CompatibilityNode instances in the graph. \"\"\"\n        with self.groupedGraphModification(\"Upgrade all Nodes\"):\n            nodes = [n for n in self._graph._compatibilityNodes.values() if n.canUpgrade]\n            sortedNodes = sorted(nodes, key=lambda x: x.name)\n            for node in sortedNodes:\n                self.upgradeNode(node)\n\n    @Slot()\n    def forceNodesStatusUpdate(self):\n        \"\"\" Force re-evaluation of graph's nodes status. \"\"\"\n        self._graph.updateStatusFromCache(force=True)\n\n    @Slot(Attribute, QJsonValue)\n    def appendAttribute(self, attribute, value=QJsonValue()):\n        if isinstance(value, QJsonValue):\n            if value.isArray():\n                pyValue = value.toArray().toVariantList()\n            else:\n                pyValue = None if value.isNull() else value.toObject()\n        else:\n            pyValue = value\n        self.push(commands.ListAttributeAppendCommand(self._graph, attribute, pyValue))\n\n    @Slot(Attribute)\n    def removeAttribute(self, attribute):\n        self.push(commands.ListAttributeRemoveCommand(self._graph, attribute))\n\n    @Slot(Attribute)\n    def removeImage(self, image):\n        if image is None:\n            return\n        with self.groupedGraphModification(\"Remove Image\"):\n            # Look if the viewpoint's intrinsic is used by another viewpoint\n            # If not, remove it\n            intrinsicId = image.intrinsicId.value\n\n            intrinsicUsed = False\n            for intrinsic in self.cameraInit.attribute(\"viewpoints\").getSerializedValue():\n                if image.getSerializedValue() != intrinsic and intrinsic['intrinsicId'] == intrinsicId:\n                    intrinsicUsed = True\n                    break\n\n            if not intrinsicUsed:\n                # Find the intrinsic and remove it\n                for intrinsic in self.cameraInit.attribute(\"intrinsics\"):\n                    if intrinsic.getSerializedValue()[\"intrinsicId\"] == intrinsicId:\n                        self.removeAttribute(intrinsic)\n                        break\n\n            # After every check we finally remove the attribute\n            self.removeAttribute(image)\n\n    @Slot()\n    def removeAllImages(self):\n        with self.groupedGraphModification(\"Remove All Images\"):\n            self.push(commands.RemoveImagesCommand(self._graph, [self.cameraInit]))\n\n    @Slot()\n    def removeImagesFromAllGroups(self):\n        with self.groupedGraphModification(\"Remove Images From All CameraInit Nodes\"):\n            self.push(commands.RemoveImagesCommand(self._graph, list(self.cameraInits)))\n\n    @Slot(list)\n    @Slot(list, int)\n    def selectNodes(self, nodes, command=QItemSelectionModel.SelectionFlag.ClearAndSelect):\n        \"\"\" Update selection with `nodes` using the specified `command`. \"\"\"\n        indices = [self._graph._nodes.indexOf(node) for node in nodes]\n        self.selectNodesByIndices(indices, command)\n\n    @Slot(Node)\n    @Slot(Node, int)\n    def selectFollowing(self, node: Node, command=QItemSelectionModel.SelectionFlag.ClearAndSelect):\n        \"\"\" Select all the nodes that depend on `node`. \"\"\"\n        self.selectNodes(\n            self._graph.dfsOnDiscover(startNodes=[node], reverse=True, dependenciesOnly=True)[0], command\n        )\n        self.selectedNode = node\n\n    @Slot(int)\n    @Slot(int, int)\n    def selectNodeByIndex(self, index: int, command=QItemSelectionModel.SelectionFlag.ClearAndSelect):\n        \"\"\" Update selection with node at the given `index` using the specified `command`. \"\"\"\n        if isinstance(command, int):\n            command = QItemSelectionModel.SelectionFlag(command)\n\n        self.selectNodesByIndices([index], command)\n\n        if self._nodeSelection.isRowSelected(index):\n            self.selectedNode = self._graph.nodes.at(index)\n\n    @Slot(list)\n    @Slot(list, int)\n    def selectNodesByIndices(\n        self, indices: list[int], command=QItemSelectionModel.SelectionFlag.ClearAndSelect\n    ):\n        \"\"\"\n        Update selection with node at given `indices` using the specified `command`.\n\n        Args:\n            indices: The list of indices to select.\n            command: The selection command to use.\n        \"\"\"\n        if isinstance(command, int):\n            command = QItemSelectionModel.SelectionFlag(command)\n\n        itemSelection = QItemSelection()\n        for index in indices:\n            itemSelection.select(\n                self._graph.nodes.index(index), self._graph.nodes.index(index)\n            )\n\n        self._nodeSelection.select(itemSelection, command)\n\n        if self.selectedNode and not self.isSelected(self.selectedNode):\n            self.selectedNode = None\n\n    def iterSelectedNodes(self) -> Iterator[Node]:\n        \"\"\" Iterate over the currently selected nodes. \"\"\"\n        for idx in self._nodeSelection.selectedRows():\n            yield self._graph.nodes.at(idx.row())\n\n    @Slot(result=list)\n    def getSelectedNodes(self) -> list[Node]:\n        \"\"\" Return the list of selected Node instances. \"\"\"\n        return list(self.iterSelectedNodes())\n\n    @Slot(Node, result=bool)\n    def isSelected(self, node: Node) -> bool:\n        \"\"\" Whether `node` is part of the current selection. \"\"\"\n        return self._nodeSelection.isRowSelected(self._graph.nodes.indexOf(node))\n\n    @Slot()\n    def clearNodeSelection(self):\n        \"\"\" Clear all node selection. \"\"\"\n        self.selectedNode = None\n        self._nodeSelection.clear()\n\n    def clearNodeHover(self):\n        \"\"\" Reset currently hovered node to None. \"\"\"\n        self.hoveredNode = None\n\n    @Slot(str)\n    def setSelectedNodesColor(self, color: str):\n        \"\"\"\n        Sets the Provided color on the selected Nodes.\n\n        Args:\n            color (str): Hex code of the color to be set on the nodes.\n        \"\"\"\n        # Update the color attribute of the nodes which are currently selected\n        with self.groupedGraphModification(\"Set Nodes Color\"):\n            # For each of the selected nodes -> Check if the node has a color -> Apply the color if it has\n            for node in self.iterSelectedNodes():\n                if node.hasInternalAttribute(\"color\"):\n                    self.setAttribute(node.internalAttribute(\"color\"), color)\n\n    @Slot(result=str)\n    def getSelectedNodesContent(self) -> str:\n        \"\"\"\n        Serialize the current node selection and return it as JSON formatted string.\n\n        Returns an empty string if the selection is empty.\n        \"\"\"\n        if not self._nodeSelection.hasSelection():\n            return \"\"\n        graphData = self._graph.serializePartial(self.getSelectedNodes())\n        return json.dumps(graphData, indent=4)\n\n    @Slot(str, QPoint, result=list)\n    def pasteNodes(self, serializedData: str, position: Optional[QPoint]=None) -> list[Node]:\n        \"\"\"\n        Import string-serialized graph content `serializedData` in the current graph, optionally at the given\n        `position`.\n        If the `serializedData` does not contain valid serialized graph data, nothing is done.\n\n        This method can be used with the result of \"getSelectedNodesContent\".\n        But it also accepts any serialized content that matches the graph data or graph content format.\n\n        For example, it is enough to have:\n        {\"nodeName_1\": {\"nodeType\":\"CameraInit\"}, \"nodeName_2\": {\"nodeType\":\"FeatureMatching\"}}\n        in `serializedData` to create a default CameraInit and a default FeatureMatching nodes.\n\n        Args:\n            serializedData: The string-serialized graph data.\n            position: The position where to paste the nodes. If None, the nodes are pasted at (0, 0).\n\n        Returns:\n            list: the list of Node objects that were pasted and added to the graph\n        \"\"\"\n        try:\n            graphData = json.loads(serializedData)\n        except json.JSONDecodeError:\n            logging.warning(\"Content is not a valid JSON string.\")\n            return []\n\n        pos = Position(position.x(), position.y()) if position else Position(0, 0)\n        result = self.push(commands.PasteNodesCommand(self._graph, graphData, pos))\n        if result is False:\n            logging.warning(\"Content is not a valid graph data.\")\n            return []\n        return result\n\n    @Slot(Node, result=bool)\n    def canComputeNode(self, node: Node) -> bool:\n        \"\"\" Check if the node can be computed. \"\"\"\n        if node.isCompatibilityNode or not node.isComputableType or node.getLocked():\n            return False\n        if node.isComputed:\n            return True\n        if self._graph.canComputeTopologically(node) and self._graph.canSubmitOrCompute(node) % 2 == 1:\n            return True\n        return False\n\n    @Slot(Node, result=bool)\n    def canSubmitNode(self, node: Node) -> bool:\n        \"\"\" Check if the node can be submitted. \"\"\"\n        if node.isCompatibilityNode or not node.isComputableType or node.getLocked():\n            return False\n        if node.isComputed:\n            return True\n        if self._graph.canComputeTopologically(node) and self._graph.canSubmitOrCompute(node)> 1:\n            return True\n        return False\n\n    undoStack = Property(QObject, lambda self: self._undoStack, constant=True)\n    graphChanged = Signal()\n    graph = Property(Graph, lambda self: self._graph, notify=graphChanged)\n    taskManager = Property(TaskManager, lambda self: self._taskManager, constant=True)\n    nodes = Property(QObject, lambda self: self._graph.nodes, notify=graphChanged)\n    layout = Property(GraphLayout, lambda self: self._layout, constant=True)\n\n    computeStatusChanged = Signal()\n    computing = Property(bool, isComputing, notify=computeStatusChanged)\n    computingExternally = Property(bool, isComputingExternally, notify=computeStatusChanged)\n    computingLocally = Property(bool, isComputingLocally, notify=computeStatusChanged)\n    canSubmit = Property(bool, lambda self: len(submitters), constant=True)\n\n    sortedDFSChunks = Property(QObject, lambda self: self._sortedDFSChunks, constant=True)\n    lockedChanged = Signal()\n\n    selectedNodeChanged = Signal()\n    # Current main selected node\n    selectedNode = makeProperty(QObject, \"_selectedNode\", selectedNodeChanged, resetOnDestroy=True)\n    # Current chunk selected (used to send signals from TaskManager to ChunksListView)\n    selectedChunkChanged = Signal()\n    selectedChunk = makeProperty(QObject, \"_selectedChunk\", selectedChunkChanged, resetOnDestroy=True)\n\n    nodeSelection = makeProperty(QObject, \"_nodeSelection\")\n\n    hoveredNodeChanged = Signal()\n    # Currently hovered node\n    hoveredNode = makeProperty(QObject, \"_hoveredNode\", hoveredNodeChanged, resetOnDestroy=True)\n\n    filePollerRefreshChanged = Signal(int)\n    filePollerRefresh = Property(int, lambda self: self._chunksMonitor.filePollerRefresh, notify=filePollerRefreshChanged)\n"
  },
  {
    "path": "meshroom/ui/palette.py",
    "content": "from PySide6.QtCore import QObject, Qt, Slot, Property, Signal\nfrom PySide6.QtGui import QPalette, QColor\nfrom PySide6.QtWidgets import QApplication\n\n\nclass PaletteManager(QObject):\n    \"\"\"\n    Manages QApplication's palette and provides a toggle between a dark and a light theme.\n    \"\"\"\n    def __init__(self, qmlEngine, parent=None):\n        super().__init__(parent)\n        self.qmlEngine = qmlEngine\n        darkPalette = QPalette()\n        window = QColor(50, 52, 55)\n        text = QColor(200, 200, 200)\n        disabledText = text.darker(170)\n        base = window.darker(150)\n        button = window.lighter(115)\n        highlight = QColor(42, 130, 218)\n        dark = window.darker(170)\n\n        darkPalette.setColor(QPalette.Window, window)\n        darkPalette.setColor(QPalette.WindowText, text)\n        darkPalette.setColor(QPalette.Disabled, QPalette.WindowText, disabledText)\n        darkPalette.setColor(QPalette.Base, base)\n        darkPalette.setColor(QPalette.AlternateBase, QColor(46, 47, 48))\n        darkPalette.setColor(QPalette.ToolTipBase, base)\n        darkPalette.setColor(QPalette.ToolTipText, text)\n        darkPalette.setColor(QPalette.Text, text)\n        darkPalette.setColor(QPalette.Disabled, QPalette.Text, disabledText)\n        darkPalette.setColor(QPalette.Button, button)\n        darkPalette.setColor(QPalette.ButtonText, text)\n        darkPalette.setColor(QPalette.Disabled, QPalette.ButtonText, disabledText)\n\n        darkPalette.setColor(QPalette.Mid, button.lighter(120))\n        darkPalette.setColor(QPalette.Highlight, highlight)\n        darkPalette.setColor(QPalette.Disabled, QPalette.Highlight, QColor(80, 80, 80))\n        darkPalette.setColor(QPalette.HighlightedText, Qt.white)\n        darkPalette.setColor(QPalette.Disabled, QPalette.HighlightedText, QColor(127, 127, 127))\n        darkPalette.setColor(QPalette.Shadow, Qt.black)\n        darkPalette.setColor(QPalette.Link, highlight.lighter(130))\n\n        self.darkPalette = darkPalette\n        self.defaultPalette = QApplication.instance().palette()\n        self.defaultPalette.setColor(QPalette.Text, QColor(50, 50, 50))\n        self.defaultPalette.setColor(QPalette.HighlightedText, Qt.black)\n        self.togglePalette()\n\n    @Slot()\n    def togglePalette(self):\n        app = QApplication.instance()\n        if app.palette() == self.darkPalette:\n            app.setPalette(self.defaultPalette)\n        else:\n            app.setPalette(self.darkPalette)\n        if self.qmlEngine.rootObjects():\n            self.qmlEngine.reload()\n        self.paletteChanged.emit()\n\n    paletteChanged = Signal()\n    palette = Property(QPalette, lambda self: QApplication.instance().palette(), notify=paletteChanged)\n    alternateBase = Property(QColor, lambda self: self.palette.color(QPalette.AlternateBase), notify=paletteChanged)\n    base = Property(QColor, lambda self: self.palette.color(QPalette.Base), notify=paletteChanged)\n    button = Property(QColor, lambda self: self.palette.color(QPalette.Button), notify=paletteChanged)\n    buttonText = Property(QColor, lambda self: self.palette.color(QPalette.ButtonText), notify=paletteChanged)\n    disabledButtonText = Property(QColor, lambda self: self.palette.color(QPalette.Disabled, QPalette.ButtonText), notify=paletteChanged)\n    highlight = Property(QColor, lambda self: self.palette.color(QPalette.Highlight), notify=paletteChanged)\n    disabledHighlight = Property(QColor, lambda self: self.palette.color(QPalette.Disabled, QPalette.Highlight), notify=paletteChanged)\n    highlightedText = Property(QColor, lambda self: self.palette.color(QPalette.HighlightedText), notify=paletteChanged)\n    disabledHighlightedText = Property(QColor, lambda self: self.palette.color(QPalette.Disabled, QPalette.HighlightedText), notify=paletteChanged)\n    link = Property(QColor, lambda self: self.palette.color(QPalette.Link), notify=paletteChanged)\n    mid = Property(QColor, lambda self: self.palette.color(QPalette.Mid), notify=paletteChanged)\n    shadow = Property(QColor, lambda self: self.palette.color(QPalette.Shadow), notify=paletteChanged)\n    text = Property(QColor, lambda self: self.palette.color(QPalette.Text), notify=paletteChanged)\n    disabledText = Property(QColor, lambda self: self.palette.color(QPalette.Disabled, QPalette.Text), notify=paletteChanged)\n    toolTipBase = Property(QColor, lambda self: self.palette.color(QPalette.ToolTipBase), notify=paletteChanged)\n    toolTipText = Property(QColor, lambda self: self.palette.color(QPalette.ToolTipText), notify=paletteChanged)\n    window = Property(QColor, lambda self: self.palette.color(QPalette.Window), notify=paletteChanged)\n    windowText = Property(QColor, lambda self: self.palette.color(QPalette.WindowText), notify=paletteChanged)\n    disabledWindowText = Property(QColor, lambda self: self.palette.color(QPalette.Disabled, QPalette.WindowText), notify=paletteChanged)\n"
  },
  {
    "path": "meshroom/ui/qml/AboutDialog.qml",
    "content": "import QtQuick\nimport QtQuick.Controls\nimport QtQuick.Layouts\nimport Utils 1.0\nimport MaterialIcons 2.2\n\n\n/**\n * Meshroom \"About\" window\n */\n\nDialog {\n    id: root\n\n    x: parent.width / 2 - width / 2\n    width: 600\n\n    // Fade in transition\n    enter: Transition {\n        NumberAnimation {\n            property: \"opacity\"\n            from: 0.0\n            to: 1.0\n        }\n    }\n\n    modal: true\n    closePolicy: Dialog.CloseOnEscape | Dialog.CloseOnPressOutside\n    padding: 30\n    topPadding: 0  // Header provides top padding\n\n    header: Pane {\n        background: Item {}\n        MaterialToolButton {\n            text: MaterialIcons.close\n            anchors.right: parent.right\n            onClicked: root.close()\n        }\n    }\n\n    ColumnLayout {\n        width: parent.width\n        spacing: 6\n\n        // Logo + Version info\n        Column {\n            Layout.fillWidth: true\n            Image {\n                anchors.horizontalCenter: parent.horizontalCenter\n                source: \"../img/meshroom-tagline-vertical.svg\"\n                sourceSize: Qt.size(222, 180)\n            }\n            TextArea {\n                id: config\n                width: parent.width\n                readOnly: true\n                horizontalAlignment: TextArea.AlignHCenter\n                selectByKeyboard: true\n                selectByMouse: true\n                text: \"Version \" + Qt.application.version + \"\\n\"\n                      + MeshroomApp.systemInfo[\"platform\"] + \" \\n\"\n                      + MeshroomApp.systemInfo[\"python\"] + \"\\n\"\n                      + MeshroomApp.systemInfo[\"pyside\"]\n            }\n        }\n\n        SystemPalette { id: systemPalette }\n\n        // Links\n        Row {\n            spacing: 4\n            Layout.alignment: Qt.AlignHCenter\n            MaterialToolButton {\n                text: MaterialIcons.public_\n                font.pointSize: 21\n                ToolTip.text: \"AliceVision Website\"\n                onClicked: Qt.openUrlExternally(\"https://alicevision.org\")\n            }\n            MaterialToolButton {\n                text: MaterialIcons.favorite\n                font.pointSize: 21\n                ToolTip.text: \"Donate to get a better software\"\n                onClicked: Qt.openUrlExternally(\"https://alicevision.org/association/#donate\")\n            }\n            ToolButton {\n                icon.source: \"../img/github-mark-white.svg\"\n                icon.width: 24\n                icon.height: 24\n                icon.color: palette.text\n                ToolTip.text: \"Meshroom on Github\"\n                ToolTip.visible: hovered\n                onClicked: Qt.openUrlExternally(\"https://github.com/alicevision/Meshroom\")\n            }\n            MaterialToolButton {\n                text: MaterialIcons.bug_report\n                font.pointSize: 21\n                ToolTip.text: \"Report a Bug (GitHub account required)\"\n                property string body: \"**Configuration**\\n\\n\" + config.text\n                onClicked: Qt.openUrlExternally(\"https://github.com/alicevision/Meshroom/issues/new?body=\"+body)\n            }\n            MaterialToolButton {\n                text: MaterialIcons.forum\n                font.pointSize: 21\n                ToolTip.text: \"Public Mailing-List (open discussions, use-cases, problems, best practices...)\"\n                onClicked: Qt.openUrlExternally(\"https://groups.google.com/forum/#!forum/alicevision\")\n            }\n            MaterialToolButton {\n                text: MaterialIcons.mail\n                font.pointSize: 21\n                ToolTip.text: \"Private Contact (alicevision-team@googlegroups.com)\"\n                onClicked: Qt.openUrlExternally(\"mailto:alicevision-team@googlegroups.com\")\n            }\n        }\n\n        // Copyright\n        RowLayout {\n            spacing: 2\n            Layout.alignment: Qt.AlignHCenter\n            Label {\n                font.family: MaterialIcons.fontFamily\n                text: MaterialIcons.copyright\n                font.pointSize: 10\n            }\n            Label {\n                text: \"2010-2025 AliceVision contributors\"\n            }\n        }\n\n        // Spacer\n        Rectangle {\n            width: 50\n            height: 1\n            color: systemPalette.mid\n            Layout.alignment: Qt.AlignHCenter\n        }\n\n        // OpenSource licenses\n        Label {\n             text: \"Changelog & Open Source Licenses\"\n             Layout.alignment: Qt.AlignHCenter\n        }\n\n        ListView {\n            Layout.fillWidth: true\n            implicitHeight: childrenRect.height\n            spacing: 2\n            interactive: false\n\n            model: MeshroomApp.changelogModel.concat(MeshroomApp.licensesModel)\n\n            // Exclusive ButtonGroup for licenses entries\n            ButtonGroup { id: licensesGroup; exclusive: true }\n\n            delegate: ColumnLayout {\n                width: ListView.view.width\n                Button {\n                    id: sectionButton\n                    flat: true\n                    text: modelData.title\n                    font.pointSize: 10\n                    font.bold: true\n                    checkable: true\n                    ButtonGroup.group: licensesGroup\n                    Layout.alignment: Qt.AlignHCenter\n                }\n\n                Loader {\n                    Layout.fillWidth: true\n                    active: sectionButton.checked\n                    Layout.preferredHeight: active ? 210 : 0\n                    visible: active\n\n                    // Log display\n                    sourceComponent: ScrollView {\n\n                        Component.onCompleted: {\n                            // Try to load the local file\n                            var url = Filepath.stringToUrl(modelData.localUrl)\n                            // Fallback to the online url if file is not found\n                            if (!Filepath.exists(url))\n                                url = modelData.onlineUrl\n                            Request.get(url,\n                                        function(xhr) {\n                                            if (xhr.readyState === XMLHttpRequest.DONE)\n                                            {\n                                                // Status is OK\n                                                if (xhr.status === 200)\n                                                    textArea.text = MeshroomApp.markdownToHtml(xhr.responseText)\n                                                else\n                                                    textArea.text = \"Could not load license file. Available online at <a href='\" + url + \"'>\"+ url + \"</a>.\"\n                                            }\n                                        })\n                        }\n\n                        background: Rectangle { color: palette.base }\n\n                        TextArea {\n                            id: textArea\n                            readOnly: true\n                            implicitWidth: parent.implicitWidth\n                            selectByMouse: true\n                            selectByKeyboard: true\n                            wrapMode: TextArea.WrapAnywhere\n                            textFormat: TextEdit.RichText\n                            onLinkActivated: function(link) { Qt.openUrlExternally(link) }\n                        }\n                    }\n                }\n            }\n        }\n    }\n}\n"
  },
  {
    "path": "meshroom/ui/qml/Application.qml",
    "content": "import QtCore\n\nimport QtQuick\nimport QtQuick.Controls\nimport QtQuick.Layouts\nimport QtQml.Models\n\nimport Qt.labs.platform as Platform\nimport QtQuick.Dialogs\n\nimport GraphEditor 1.0\nimport MaterialIcons 2.2\nimport Utils 1.0\nimport Controls 1.0\n\nPage {\n    id: root\n\n    property alias computingAtExitDialog: computingAtExitDialog\n    property alias unsavedDialog: unsavedDialog\n    property alias workspaceView: workspaceView\n\n    readonly property var scenefile: _currentScene ? _currentScene.graph.filepath : \"\";\n\n    onScenefileChanged: {\n        // Check if we are not currently saving and emit the currentProjectChanged signal\n        if (! _currentScene.graph.isSaving) {\n            // Refresh the NodeEditor\n            nodeEditor.refresh();\n        }\n    }\n\n    Settings {\n        id: settingsUILayout\n        category: \"UILayout\"\n        property alias showGraphEditor: graphEditorVisibilityCB.checked\n        property alias showImageViewer: imageViewerVisibilityCB.checked\n        property alias showViewer3D: viewer3DVisibilityCB.checked\n        property alias showImageGallery: imageGalleryVisibilityCB.checked\n        property alias showTextViewer: textViewerVisibilityCB.checked\n    }\n    \n    Settings {\n        id: nodeActionsSettings\n        category: \"NodeActions\"\n        property alias confirmBeforeDelete: nodeActionsConfirmDelete.checked\n    }\n\n\n    property url imagesFolder: {\n        var recentImportedImagesFolders = MeshroomApp.recentImportedImagesFolders\n        if (recentImportedImagesFolders.length > 0) {\n            for (var i = 0; i < recentImportedImagesFolders.length; i++) {\n                if (Filepath.exists(recentImportedImagesFolders[i]))\n                    return Filepath.stringToUrl(recentImportedImagesFolders[i])\n                else\n                    MeshroomApp.removeRecentImportedImagesFolder(Filepath.stringToUrl(recentImportedImagesFolders[i]))\n            }\n        }\n        return \"\"\n    }\n\n    Component {\n        id: invalidFilepathDialog\n\n        MessageDialog {\n            title: \"Invalid Filepath\"\n\n            required property string filepath\n\n            preset: \"Warning\"\n            text: \"The provided filepath is not valid.\"\n            detailedText: \"Filepath: \" + filepath\n            helperText: \"Please provide a valid filepath to save the file.\"\n\n            standardButtons: Dialog.Ok\n            onClosed: destroy()\n        }\n    }\n\n    Component {\n        id: permissionsDialog\n\n        MessageDialog {\n            title: \"Permission Denied\"\n\n            required property string filepath\n\n            preset: \"Warning\"\n            text: \"The location does not exist or you do not have necessary permissions to save to the provided filepath.\"\n            detailedText: \"Filepath: \" + filepath\n            helperText: \"Please check the location or permissions and try again or choose a different location.\"\n\n            standardButtons: Dialog.Ok\n            onClosed: destroy()\n        }\n    }\n\n    function validateFilepathForSave(filepath: string, sourceSaveDialog): bool {\n        /**\n         * Return true if `filepath` is valid for saving a file to disk.\n         * Otherwise, show a warning dialog and returns false.\n         * Closing the warning dialog reopens the specified `sourceSaveDialog`, to allow the user to try again.\n         */\n        const emptyFilename = Filepath.basename(filepath).trim() === \".mg\";\n\n        // Provided filename is not valid\n        if (emptyFilename) {\n            // Instantiate the Warning Dialog with the provided filepath\n            const warningDialog = invalidFilepathDialog.createObject(root, {\"filepath\": Filepath.urlToString(filepath)});\n\n            // And open the dialog\n            warningDialog.closed.connect(sourceSaveDialog.open);\n            warningDialog.open();\n\n            return false;\n        }\n\n        // Check if the user has access to the directory where the file is to be saved\n        const hasPermission = Filepath.accessible(Filepath.dirname(filepath));\n\n        // Either the directory does not exist or is inaccessible for the user\n        if (!hasPermission) {\n            // Intantiate the permissions dialog with the provided filepath\n            const warningDialog = permissionsDialog.createObject(root, {\"filepath\": Filepath.urlToString(filepath)});\n\n            // Connect and show the dialog\n            warningDialog.closed.connect(sourceSaveDialog.open);\n            warningDialog.open();\n\n            return false;\n        }\n\n        // Everything is valid\n        return true;\n    }\n\n    // File dialogs\n    Platform.FileDialog {\n        id: saveFileDialog\n\n        property var _callback: undefined\n\n        signal closed(var result)\n\n        title: \"Save File\"\n        nameFilters: [\"Meshroom Graphs (*.mg)\"]\n        defaultSuffix: \".mg\"\n        fileMode: Platform.FileDialog.SaveFile\n        onAccepted: {\n            if (!validateFilepathForSave(currentFile, saveFileDialog))\n            {\n                return;\n            }\n\n            // Only save a valid file\n            _currentScene.saveAs(currentFile)\n            MeshroomApp.addRecentProjectFile(currentFile.toString())\n            closed(Platform.Dialog.Accepted)\n            fireCallback(Platform.Dialog.Accepted)\n        }\n        onRejected: {\n            closed(Platform.Dialog.Rejected)\n            fireCallback(Platform.Dialog.Rejected)\n        }\n\n        function fireCallback(rc)\n        {\n            // Call the callback and reset it\n            if (_callback)\n                _callback(rc)\n            _callback = undefined\n        }\n\n        // Open the unsaved dialog warning with an optional\n        // callback to fire when the dialog is accepted/discarded\n        function prompt(callback)\n        {\n            _callback = callback\n            open()\n        }\n    }\n\n    Platform.FileDialog {\n        id: saveTemplateDialog\n\n        signal closed(var result)\n\n        title: \"Save Template\"\n        nameFilters: [\"Meshroom Graphs (*.mg)\"]\n        defaultSuffix: \".mg\"\n        fileMode: Platform.FileDialog.SaveFile\n        onAccepted: {\n            if (!validateFilepathForSave(currentFile, saveTemplateDialog))\n            {\n                return;\n            }\n\n            // Only save a valid template\n            _currentScene.saveAsTemplate(currentFile)\n            closed(Platform.Dialog.Accepted)\n            MeshroomApp.reloadTemplateList()\n        }\n        onRejected: closed(Platform.Dialog.Rejected)\n    }\n\n    Platform.FileDialog {\n        id: loadTemplateDialog\n        title: \"Load Template\"\n        nameFilters: [\"Meshroom Graphs (*.mg)\"]\n        onAccepted: {\n            // Open the template as a regular file\n            if (_currentScene.load(currentFile)) {\n                MeshroomApp.addRecentProjectFile(currentFile.toString())\n            }\n        }\n    }\n\n    Platform.FileDialog {\n        id: importImagesDialog\n        title: \"Import Images\"\n        fileMode: Platform.FileDialog.OpenFiles\n        nameFilters: []\n        onAccepted: {\n            _currentScene.importImagesUrls(currentFiles)\n            imagesFolder = Filepath.dirname(currentFiles[0])\n            MeshroomApp.addRecentImportedImagesFolder(imagesFolder)\n        }\n    }\n\n    Platform.FileDialog {\n        id: importProjectDialog\n        title: \"Import Project\"\n        fileMode: Platform.FileDialog.OpenFile\n        nameFilters: [\"Meshroom Graphs (*.mg)\"]\n        onAccepted: {\n            graphEditor.uigraph.importProject(currentFile)\n        }\n    }\n\n    Item {\n        id: computeManager\n\n        // Evaluate if graph computation can be submitted externally\n        property bool canSubmit: _currentScene ?\n                                 _currentScene.canSubmit                 // current setup allows to compute externally\n                                 && _currentScene.graph.filepath :       // graph is saved on disk\n                                 false\n\n        function compute(nodes, force) {\n            if (!force && !_currentScene.graph.filepath) {\n                unsavedComputeDialog.selectedNodes = nodes;\n                unsavedComputeDialog.open();\n            }\n            else {\n                try {\n                    _currentScene.execute(nodes)\n                }\n                catch (error) {\n                    const data = ErrorHandler.analyseError(error)\n                    if (data.context === \"COMPUTATION\")\n                        computeSubmitErrorDialog.openError(data.type, data.msg, nodes)\n                }\n            }\n        }\n\n        function submit(nodes) {\n            if (!canSubmit) {\n                unsavedSubmitDialog.open()\n            } else {\n                try {\n                    _currentScene.submit(nodes)\n                }\n                catch (error) {\n                    const data = ErrorHandler.analyseError(error)\n                    if (data.context === \"SUBMITTING\")\n                        computeSubmitErrorDialog.openError(data.type, data.msg, nodes)\n                }\n            }\n        }\n\n        MessageDialog {\n            id: computeSubmitErrorDialog\n\n            property string errorType  // Used to specify signals' behavior\n            property var currentNode: null\n\n            function openError(type, msg, node) {\n                errorType = type\n                switch (type) {\n                    case \"Already Submitted\": {\n                            this.setupPendingStatusError(msg, node)\n                            break\n                    }\n                    case \"Compatibility Issue\": {\n                        this.setupCompatibilityIssue(msg)\n                        break\n                    }\n                    default: {\n                        this.onlyDisplayError(msg)\n                    }\n                }\n\n                this.open()\n            }\n\n            function onlyDisplayError(msg) {\n                text = msg\n\n                standardButtons = Dialog.Ok\n            }\n\n            function setupPendingStatusError(msg, node) {\n                currentNode = node\n                text = msg + \"\\n\\nDo you want to Clear Pending Status and Start Computing?\"\n\n                standardButtons = (Dialog.Ok | Dialog.Cancel)\n            }\n\n            function setupCompatibilityIssue(msg) {\n                text = msg + \"\\n\\nDo you want to open the Compatibility Manager?\"\n\n                standardButtons = (Dialog.Ok | Dialog.Cancel)\n            }\n\n            canCopy: false\n            icon.text: MaterialIcons.warning\n            parent: Overlay.overlay\n            preset: \"Warning\"\n            title: \"Computation/Submitting\"\n            text: \"\"\n\n            onAccepted: {\n                switch (errorType) {\n                    case \"Already Submitted\": {\n                        close()\n                        _currentScene.graph.clearSubmittedNodes()\n                        _currentScene.execute(currentNode)\n                        break\n                    }\n                    case \"Compatibility Issue\": {\n                        close()\n                        compatibilityManager.open()\n                        break\n                    }\n                    default: close()\n                }\n            }\n\n            onRejected: close()\n        }\n\n        MessageDialog {\n            id: unsavedComputeDialog\n\n            property var selectedNodes: null\n\n            canCopy: false\n            icon.text: MaterialIcons.warning\n            parent: Overlay.overlay\n            preset: \"Warning\"\n            title: \"Unsaved Project\"\n            text: \"Saving the project is required.\"\n            helperText: \"Choose a location to save the project, or use the default temporary path.\"\n            standardButtons: Dialog.Discard | Dialog.Cancel | Dialog.Save\n\n            Component.onCompleted: {\n                // Set up discard button text\n                standardButton(Dialog.Discard).text = \"Continue in Temp Folder\"\n                standardButton(Dialog.Save).text = \"Save As\"\n            }\n\n            onDiscarded: {\n                _currentScene.saveAsTemp()\n                close()\n                computeManager.compute(selectedNodes, true)\n            }\n\n            onAccepted: {\n                initFileDialogFolder(saveFileDialog)\n                saveFileDialog.prompt(function(rc) {\n                    computeManager.compute(selectedNodes, true)\n                })\n            }\n        }\n\n        MessageDialog {\n            id: unsavedSubmitDialog\n\n            canCopy: false\n            icon.text: MaterialIcons.warning\n            parent: Overlay.overlay\n            preset: \"Warning\"\n            title: \"Unsaved Project\"\n            text: \"The project cannot be submitted if it remains unsaved.\"\n            helperText: \"Save the project to be able to submit it?\"\n            standardButtons: Dialog.Cancel | Dialog.Save\n\n            onDiscarded: close()\n            onAccepted: saveAsAction.trigger()\n        }\n\n        MessageDialog {\n            id: fileModifiedDialog\n\n            canCopy: false\n            icon.text: MaterialIcons.warning\n            parent: Overlay.overlay\n            preset: \"Warning\"\n            title: \"File Modified\"\n            text: \"The file has been modified by another instance.\"\n            detailedText: \"Do you want to overwrite the file?\"\n\n            // Add a reload file button next to the save button\n            footer: DialogButtonBox {\n                position: DialogButtonBox.Footer\n                standardButtons: Dialog.Save | Dialog.Cancel\n\n                Button {\n                    text: \"Reload File\"\n\n                    onClicked: {\n                        _currentScene.load(_currentScene.graph.filepath)\n                        fileModifiedDialog.close()\n                    }\n                }\n            }\n\n            onAccepted: _currentScene.save()\n            onDiscarded: close()\n        }\n    }\n\n    // Message dialogs\n    MessageDialog {\n        id: unsavedDialog\n\n        property var _callback: undefined\n\n        title: (_currentScene ? Filepath.basename(_currentScene.graph.filepath) : \"\") || \"Unsaved Project\"\n        preset: \"Info\"\n        canCopy: false\n        text: _currentScene && _currentScene.graph.filepath ? \"Current project has unsaved modifications.\"\n                                             : \"Current project has not been saved.\"\n        helperText: _currentScene && _currentScene.graph.filepath ? \"Would you like to save those changes?\"\n                                                   : \"Would you like to save this project?\"\n\n        standardButtons: Dialog.Save | Dialog.Cancel | Dialog.Discard\n\n        onDiscarded: {\n            close() // BUG ? discard does not close window\n            fireCallback()\n        }\n\n        onRejected: {\n            _window.isClosing = false\n        }\n\n        onAccepted: {\n            // Save current file\n            if (saveAction.enabled && _currentScene.graph.filepath) {\n                saveAction.trigger()\n                fireCallback()\n            }\n            // Open \"Save As\" dialog\n            else {\n                saveFileDialog.prompt(function(rc) {\n                    if (rc === Platform.Dialog.Accepted)\n                        fireCallback()\n                })\n            }\n        }\n\n        function fireCallback()\n        {\n            // Call the callback and reset it\n            if (_callback)\n                _callback()\n            _callback = undefined\n        }\n\n        // Open the unsaved dialog warning with an optional\n        // callback to fire when the dialog is accepted/discarded\n        function prompt(callback)\n        {\n            _callback = callback\n            open()\n        }\n    }\n\n    MessageDialog {\n        id: computingAtExitDialog\n        title: \"Operation in progress\"\n        modal: true\n        canCopy: false\n        Label {\n            text: \"Please stop any local computation before exiting Meshroom\"\n        }\n    }\n\n    MessageDialog {\n        // Popup displayed while the application\n        // is busy building intrinsics while importing images\n        id: buildingIntrinsicsDialog\n        modal: true\n        visible: _currentScene ? _currentScene.buildingIntrinsics : false\n        closePolicy: Popup.NoAutoClose\n        title: \"Initializing Cameras\"\n        icon.text: MaterialIcons.camera\n        icon.font.pointSize: 10\n        canCopy: false\n        standardButtons: Dialog.NoButton\n\n        detailedText:  \"Extracting images metadata and creating Camera intrinsics...\"\n        ProgressBar {\n            indeterminate: true\n            Layout.fillWidth: true\n        }\n    }\n\n    AboutDialog {\n        id: aboutDialog\n    }\n\n    DialogsFactory {\n        id: dialogsFactory\n    }\n\n    CompatibilityManager {\n        id: compatibilityManager\n        uigraph: _currentScene\n    }\n\n\n    // Actions\n    Action {\n        id: removeAllImagesAction\n        property string tooltip: \"Remove all the images from the current CameraInit group\"\n        text: \"Remove All Images\"\n        onTriggered: {\n            _currentScene.removeAllImages()\n            _currentScene.selectedViewId = \"-1\"\n        }\n    }\n\n    Action {\n        id: removeImagesFromAllGroupsAction\n        property string tooltip: \"Remove all the images from all the CameraInit groups\"\n        text: \"Remove Images From All CameraInit Nodes\"\n        onTriggered: {\n            _currentScene.removeImagesFromAllGroups()\n            _currentScene.selectedViewId = \"-1\"\n        }\n    }\n\n    Action {\n        id: reloadPluginsAction\n        property string tooltip: \"Reload the source code for all nodes from all registered plugins\"\n        text: \"Reload Plugins Source Code\"\n        shortcut: \"Ctrl+Shift+R\"\n        onTriggered: {\n            statusBar.showMessage(\"Reloading plugins...\")\n            _currentScene.reloadPlugins()  // This will handle the message to show that it finished properly\n        }\n    }\n\n    Action {\n        id: undoAction\n\n        property string tooltip: 'Undo \"' + (_currentScene ? _currentScene.undoStack.undoText : \"Unknown\") + '\"'\n        text: \"Undo\"\n        shortcut: \"Ctrl+Z\"\n        enabled: _currentScene ? _currentScene.undoStack.canUndo && _currentScene.undoStack.isUndoableIndex : false\n        onTriggered: _currentScene.undoStack.undo()\n    }\n\n    Action {\n        id: redoAction\n\n        property string tooltip: 'Redo \"' + (_currentScene ? _currentScene.undoStack.redoText : \"Unknown\") + '\"'\n        text: \"Redo\"\n        shortcut: \"Ctrl+Shift+Z\"\n        enabled: _currentScene ? _currentScene.undoStack.canRedo && !_currentScene.undoStack.lockedRedo : false\n        onTriggered: _currentScene.undoStack.redo()\n    }\n\n    Action {\n        id: cutAction\n\n        property string tooltip: \"Cut Selected Node(s)\"\n        text: \"Cut Node(s)\"\n        enabled: _currentScene ? _currentScene.nodeSelection.hasSelection : false\n        onTriggered: {\n            graphEditor.copyNodes()\n            graphEditor.uigraph.removeSelectedNodes()\n        }\n    }\n\n    Action {\n        id: copyAction\n\n        property string tooltip: \"Copy Selected Node(s)\" \n        text: \"Copy Node(s)\"\n        enabled: _currentScene ? _currentScene.nodeSelection.hasSelection : false\n        onTriggered: graphEditor.copyNodes()\n    }\n\n    Action {\n        id: pasteAction\n\n        property string tooltip: \"Paste the clipboard content to the project if it contains valid nodes\"\n        text: \"Paste Node(s)\"\n        onTriggered: graphEditor.pasteNodes()\n    }\n\n    Action {\n        id: loadTemplateAction\n\n        property string tooltip: \"Load a template like a regular project file (any \\\"CopyFiles\\\" node will be displayed)\"\n        text: \"Load Template\"\n        onTriggered: {\n            ensureSaved(function() {\n                initFileDialogFolder(loadTemplateDialog)\n                loadTemplateDialog.open()\n            })\n        }\n    }\n\n    header: RowLayout {\n        spacing: 0\n        MaterialToolButton {\n            id: homeButton\n            text: MaterialIcons.home\n\n            font.pointSize: 18\n\n            background: Rectangle {\n                color: homeButton.hovered ? activePalette.highlight : Qt.darker(activePalette.window, 1.15)\n                border.color: Qt.darker(activePalette.window, 1.15)\n            }\n\n            onClicked: {\n                if (!ensureNotComputing())\n                    return\n                ensureSaved(function() {\n                    _currentScene.clear()\n                    if (mainStack.depth == 1)\n                        mainStack.replace(\"Homepage.qml\")\n                    else\n                        mainStack.pop()\n                })\n            }\n        }\n        MenuBar {\n            palette.window: Qt.darker(activePalette.window, 1.15)\n            Menu {\n                title: \"File\"\n                Action {\n                    id: newAction\n                    text: \"New\"\n                    shortcut: \"Ctrl+N\"\n                    onTriggered: ensureSaved(function() {\n                        _currentScene.new()\n                    })\n                }\n                Menu {\n                    id: newPipelineMenu\n                    title: \"New Pipeline\"\n                    enabled: newPipelineMenuItems.model !== undefined && newPipelineMenuItems.model.length > 0\n                    property int maxWidth: 1000\n                    property int fullWidth: {\n                        var result = 0;\n                        for (var i = 0; i < count; ++i) {\n                            var item = itemAt(i)\n                            result = Math.max(item.implicitWidth + item.padding * 2, result)\n                        }\n                        return result;\n                    }\n                    implicitWidth: fullWidth\n                    Repeater {\n                        id: newPipelineMenuItems\n                        model: MeshroomApp.pipelineTemplateFiles\n                        MenuItem {\n                            onTriggered: ensureSaved(function() {\n                                _currentScene.new(modelData[\"key\"])\n                            })\n\n                            text: fileTextMetrics.elidedText\n                            TextMetrics {\n                                id: fileTextMetrics\n                                text: modelData[\"name\"]\n                                elide: Text.ElideLeft\n                                elideWidth: newPipelineMenu.maxWidth\n                            }\n\n                            ToolTip {\n                                id: toolTip\n\n                                delay: 200\n                                text: modelData[\"path\"]\n                                visible: hovered\n                                x: newPipelineMenu.implicitWidth\n                                y: newPipelineMenuItems.implicitHeight\n                            }\n                        }\n                    }\n                }\n                Action {\n                    id: openActionItem\n                    text: \"Open\"\n                    shortcut: \"Ctrl+O\"\n                    onTriggered: ensureSaved(function() {\n                            initFileDialogFolder(openFileDialog)\n                            openFileDialog.open()\n                        })\n                }\n                Menu {\n                    id: openRecentMenu\n                    title: \"Open Recent\"\n                    enabled: recentFilesMenuItems.model !== undefined && recentFilesMenuItems.model.length > 0\n                    property int maxWidth: 1000\n                    property int fullWidth: {\n                        var result = 0;\n                        for (var i = 0; i < count; ++i) {\n                            var item = itemAt(i)\n                            result = Math.max(item.implicitWidth + item.padding * 2, result)\n                        }\n                        return result\n                    }\n                    implicitWidth: fullWidth\n                    Repeater {\n                        id: recentFilesMenuItems\n                        model: MeshroomApp.recentProjectFiles\n                        MenuItem {\n                            enabled: modelData[\"status\"] != 0\n                            \n                            onTriggered: ensureSaved(function() {\n                                openRecentMenu.dismiss()\n                                if (_currentScene.load(modelData[\"path\"])) {\n                                    MeshroomApp.addRecentProjectFile(modelData[\"path\"])\n                                }\n                            })\n                            \n                            text: fileTextMetrics.elidedText\n                            TextMetrics {\n                                id: fileTextMetrics\n                                text: modelData[\"path\"]\n                                elide: Text.ElideLeft\n                                elideWidth: openRecentMenu.maxWidth\n                            }\n                        }\n                    }\n                }\n                MenuSeparator { }\n                Action {\n                    id: saveAction\n                    text: \"Save\"\n                    shortcut: \"Ctrl+S\"\n                    enabled: _currentScene ? (_currentScene.graph && !_currentScene.graph.filepath) || !_currentScene.undoStack.clean : false\n                    onTriggered: {\n                        if (_currentScene.graph.filepath) {\n                            // Get current time date\n                            var date = _currentScene.graph.getFileDateVersionFromPath(_currentScene.graph.filepath)\n\n                            // Check if the file has been modified by another instance\n                            if (_currentScene.graph.fileDateVersion !== date) {\n                                fileModifiedDialog.open()\n                            } else\n                                _currentScene.save()\n                        } else {\n                            initFileDialogFolder(saveFileDialog)\n                            saveFileDialog.open()\n                        }\n                    }\n                }\n                Action {\n                    id: saveAsAction\n                    text: \"Save As...\"\n                    shortcut: \"Ctrl+Shift+S\"\n                    onTriggered: {\n                        initFileDialogFolder(saveFileDialog)\n                        saveFileDialog.open()\n                    }\n                }\n                Action {\n                    id: saveNewVersionAction\n                    text: \"Save New Version\"\n                    shortcut: \"Ctrl+Alt+S\"\n                    enabled: _currentScene && _currentScene.graph && _currentScene.graph.filepath\n                    onTriggered: {\n                        _currentScene.saveAsNewVersion()\n                        MeshroomApp.addRecentProjectFile(_currentScene.graph.filepath)\n                    }\n                }\n                MenuSeparator { }\n                Action {\n                    id: importImagesAction\n                    text: \"Import Images\"\n                    shortcut: \"Ctrl+I\"\n                    onTriggered: {\n                        initFileDialogFolder(importImagesDialog, true)\n                        importImagesDialog.open()\n                    }\n                }\n\n                MenuItem {\n                    action: removeAllImagesAction\n\n                    ToolTip {\n                        visible: parent.hovered\n                        text: removeAllImagesAction.tooltip\n                        x: parent.implicitWidth\n                        y: 0\n                    }\n                }\n\n                MenuSeparator { }\n                Menu {\n                    id: advancedMenu\n                    title: \"Advanced\"\n                    implicitWidth: 300\n\n                    Action {\n                        id: saveAsTemplateAction\n                        text: \"Save As Template...\"\n                        shortcut: Shortcut {\n                            sequence: \"Ctrl+Shift+T\"\n                            context: Qt.ApplicationShortcut\n                            onActivated: saveAsTemplateAction.triggered()\n                        }\n                        onTriggered: {\n                            initFileDialogFolder(saveTemplateDialog)\n                            saveTemplateDialog.open()\n                        }\n                    }\n\n                    MenuItem {\n                        action: loadTemplateAction\n\n                        ToolTip {\n                            visible: parent.hovered\n                            text: loadTemplateAction.tooltip\n                            x: advancedMenu.implicitWidth\n                            y: 0\n                        }\n                    }\n\n                    Action {\n                        id: importProjectAction\n                        text: \"Import Project\"\n                        shortcut: Shortcut {\n                            sequence: \"Ctrl+Shift+I\"\n                            context: Qt.ApplicationShortcut\n                            onActivated: importProjectAction.triggered()\n                        }\n                        onTriggered: {\n                            initFileDialogFolder(importProjectDialog)\n                            importProjectDialog.open()\n                        }\n                    }\n\n                    MenuItem {\n                        action: removeImagesFromAllGroupsAction\n\n                        ToolTip {\n                            visible: parent.hovered\n                            text: removeImagesFromAllGroupsAction.tooltip\n                            x: advancedMenu.implicitWidth\n                            y: 0\n                        }\n                    }\n\n                    MenuItem {\n                        action: reloadPluginsAction\n\n                        ToolTip {\n                            visible: parent.hovered\n                            text: reloadPluginsAction.tooltip\n                            x: advancedMenu.implicitWidth\n                            y: 0\n                        }\n                    }\n\n                    MenuSeparator { }\n\n                    Menu {\n                        id: nodeActionsSettingsMenu\n                        title: \"NodeActions Settings\"\n                        implicitWidth: 250\n                        \n                        MenuItem {\n                            id: nodeActionsConfirmDelete\n                            checkable: true\n                            checked: false\n                            text: \"Confirm Before Deleting Data\"\n                            ToolTip {\n                                visible: parent.hovered\n                                text: \"Show a confirmation popup before deleting the node data\"\n                                x: nodeActionsSettingsMenu.width\n                                y: 0\n                            }\n                        }\n                    }\n                }\n                MenuSeparator { }\n                Action {\n                    text: \"Quit\"\n                    onTriggered: _window.close()\n                }\n            }\n            Menu {\n                title: \"Edit\"\n                MenuItem {\n                    action: undoAction\n\n                    ToolTip {\n                        visible: parent.hovered && undoAction.enabled\n                        text: undoAction.tooltip\n                        x: parent.implicitWidth\n                        y: 0\n                    }\n                }\n                MenuItem {\n                    action: redoAction\n\n                    ToolTip {\n                        visible: parent.hovered && redoAction.enabled\n                        text: redoAction.tooltip\n                        x: parent.implicitWidth\n                        y: 0\n                    }\n                }\n                MenuItem {\n                    action: cutAction\n\n                    ToolTip {\n                        visible: parent.hovered && cutAction.enabled\n                        text: cutAction.tooltip\n                        x: parent.implicitWidth\n                        y: 0\n                    }\n                }\n                MenuItem {\n                    action: copyAction\n\n                    ToolTip {\n                        visible: parent.hovered && copyAction.enabled\n                        text: copyAction.tooltip\n                        x: parent.implicitWidth\n                        y: 0\n                    }\n                }\n                MenuItem {\n                    action: pasteAction\n\n                    ToolTip {\n                        visible: parent.hovered && pasteAction.enabled\n                        text: pasteAction.tooltip\n                        x: parent.implicitWidth\n                        y: 0\n                    }\n                }\n            }\n            Menu {\n                title: \"View\"\n                MenuItem {\n                    id: graphEditorVisibilityCB\n                    text: \"Graph Editor\"\n                    checkable: true\n                    checked: true\n                }\n                MenuItem {\n                    id: imageViewerVisibilityCB\n                    text: \"Image Viewer\"\n                    checkable: true\n                    checked: true\n                }\n                MenuItem {\n                    id: viewer3DVisibilityCB\n                    text: \"3D Viewer\"\n                    checkable: true\n                    checked: true\n                }\n                MenuItem {\n                    id: imageGalleryVisibilityCB\n                    text: \"Image Gallery\"\n                    checkable: true\n                    checked: true\n                }\n                MenuItem {\n                    id: textViewerVisibilityCB\n                    text: \"Text Viewer\"\n                    checkable: true\n                    checked: false\n                }\n                MenuSeparator {}\n                Action {\n                    text: \"Fullscreen\"\n                    checkable: true\n                    checked: _window.visibility == ApplicationWindow.FullScreen\n                    shortcut: \"Ctrl+F\"\n                    onTriggered: _window.visibility == ApplicationWindow.FullScreen ? _window.showNormal() : showFullScreen()\n                }\n            }\n            Menu {\n                title: \"Process\"\n                Action {\n                    text: \"Compute All Nodes\"\n                    onTriggered: computeManager.compute(null)\n                    enabled: _currentScene ? !_currentScene.computingLocally : false\n                }\n                Action {\n                    text: \"Submit All Nodes\"\n                    onTriggered: computeManager.submit(null)\n                    enabled: _currentScene ? _currentScene.canSubmit : false\n                }\n                MenuSeparator {}\n                Action {\n                    text: \"Stop Computation\"\n                    onTriggered: _currentScene.stopExecution()\n                    enabled: _currentScene ? _currentScene.computingLocally : false\n                }\n                MenuSeparator {}\n                Menu {\n                    id: submitterSelectionMenu\n                    title: \"Submitter Selection\"\n                    enabled: submitterItems.model !== undefined && submitterItems.model.length > 0\n                    Repeater {\n                        id: submitterItems\n                        model: MeshroomApp.submittersListModel\n                        RadioButton {\n                            text: modelData[\"name\"]\n                            checked: modelData[\"isDefault\"]\n                            onClicked: MeshroomApp.setDefaultSubmitter(modelData[\"name\"])\n                        }\n                    }\n                }\n            }\n            Menu {\n                title: \"Help\"\n                Action {\n                    text: \"Online Documentation\"\n                    onTriggered: Qt.openUrlExternally(\"https://meshroom-manual.readthedocs.io\")\n                }\n                Action {\n                    text: \"About Meshroom\"\n                    onTriggered: aboutDialog.open()\n                    // Should be StandardKey.HelpContents, but for some reason it is not stable\n                    // (may cause crash, requires pressing F1 twice after closing the popup)\n                    shortcut: \"F1\"\n                }\n            }\n        }\n\n        Rectangle {\n            Layout.fillWidth: true\n            Layout.fillHeight: true\n            color: Qt.darker(activePalette.window, 1.15)\n        }\n\n        Row {\n            // Process buttons\n            MaterialToolButton {\n                id: processButton\n\n                font.pointSize: 18\n\n                text: !(_currentScene.computingLocally) ? MaterialIcons.send : MaterialIcons.cancel_schedule_send\n\n                ToolTip.text: !(_currentScene.computingLocally) ? \"Compute\" : \"Stop Computing\"\n                ToolTip.visible: hovered\n\n                background: Rectangle {\n                    color: processButton.hovered ? activePalette.highlight : Qt.darker(activePalette.window, 1.15)\n                    border.color: Qt.darker(activePalette.window, 1.15)\n                }\n\n                onClicked: _currentScene.computingLocally ? _currentScene.stopExecution() : computeManager.compute(null)\n            }\n\n            MaterialToolButton {\n                id: submitButton\n\n                font.pointSize: 18\n\n                visible: _currentScene ? _currentScene.canSubmit : false\n                text: !(_currentScene.computingExternally) ? MaterialIcons.rocket_launch : MaterialIcons.paragliding\n\n                ToolTip.text: !(_currentScene.computingExternally) ? \"Submit on Render Farm\" : \"Interrupt Job\"\n                ToolTip.visible: hovered\n\n                background: Rectangle {\n                    color: submitButton.hovered ? activePalette.highlight : Qt.darker(activePalette.window, 1.15)\n                    border.color: Qt.darker(activePalette.window, 1.15)\n                }\n\n                onClicked: _currentScene.computingExternally ? _currentScene.stopExecution() : computeManager.submit(null)\n            }\n        }\n\n        Rectangle {\n            Layout.fillWidth: true\n            Layout.fillHeight: true\n            color: Qt.darker(activePalette.window, 1.15)\n        }\n\n        // CompatibilityManager indicator\n        ToolButton {\n            id: compatibilityIssuesButton\n            visible: compatibilityManager.issueCount\n            text: MaterialIcons.warning\n            font.family: MaterialIcons.fontFamily\n            palette.buttonText: \"#FF9800\"\n            font.pointSize: 12\n            onClicked: compatibilityManager.open()\n            ToolTip.text: \"Compatibility Issues\"\n            ToolTip.visible: hovered\n\n            background: Rectangle {\n                color: compatibilityIssuesButton.hovered ? activePalette.highlight : Qt.darker(activePalette.window, 1.15)\n                border.color: Qt.darker(activePalette.window, 1.15)\n            }\n        }\n    }\n\n    footer: ToolBar {\n        id: footer\n        padding: 1\n        leftPadding: 4\n        rightPadding: 4\n        palette.window: Qt.darker(activePalette.window, 1.15)\n\n        RowLayout {\n            anchors.fill: parent\n            spacing: 0\n\n            MaterialToolButton {\n                font.pointSize: 8\n                text: MaterialIcons.folder_open\n                ToolTip.text: \"Open Cache Folder\"\n                onClicked: Qt.openUrlExternally(Filepath.stringToUrl(_currentScene.graph.cacheDir))\n            }\n\n            TextField {\n                readOnly: true\n                selectByMouse: true\n                text: _currentScene ? _currentScene.graph.cacheDir : \"Unknown\"\n                color: Qt.darker(palette.text, 1.2)\n                background: Item {}\n            }\n\n            // Spacer to push status bar to the right\n            Item { Layout.fillWidth: true }\n\n            StatusBar {\n                id: statusBar\n                objectName: \"statusBar\"  // Expose to python\n                height: parent.height\n                defaultIcon : MaterialIcons.comment\n            }\n        }\n    }\n\n    Connections {\n        target: _currentScene\n\n        // Bind messages to DialogsFactory\n        function createDialog(func, message) {\n            var dialog = func(_window)\n            // Set text afterwards to avoid dialog sizing issues\n            dialog.title = message.title\n            dialog.text = message.text\n            dialog.detailedText = message.detailedText\n        }\n\n        function onGraphChanged() {\n            // Open CompatibilityManager after file loading if any issue is detected\n            if (compatibilityManager.issueCount)\n                compatibilityManager.open()\n            // Trigger fit to visualize all nodes\n            graphEditor.fit()\n        }\n\n        function onInfo() { createDialog(dialogsFactory.info, arguments[0]) }\n        function onWarning() { createDialog(dialogsFactory.warning, arguments[0]) }\n        function onError() { createDialog(dialogsFactory.error, arguments[0]) }\n    }\n\n    ColumnLayout {\n        anchors.fill: parent\n        spacing: 4\n\n        // \"ProgressBar\" reflecting status of all the chunks in the graph, in their process order\n        NodeChunks {\n            id: chunksListView\n            height: 6\n            Layout.fillWidth: true\n            model: _currentScene ? _currentScene.sortedDFSChunks : null\n            highlightChunks: false\n        }\n\n        MSplitView {\n            id: topBottomSplit\n            Layout.fillHeight: true\n            Layout.fillWidth: true\n\n            orientation: Qt.Vertical\n\n            // Setup global tooltip style\n            ToolTip.toolTip.background: Rectangle { color: activePalette.base; border.color: activePalette.mid }\n\n            WorkspaceView {\n                id: workspaceView\n                SplitView.fillHeight: true\n                SplitView.preferredHeight: 300\n                SplitView.minimumHeight: 80\n                currentScene: _currentScene\n                readOnly: _currentScene ? _currentScene.computing : false\n\n                function viewNode(node, mouse) {\n                    // 2D viewer\n                    viewer2D.tryLoadNode(node)\n\n                    // 3D viewer\n                    // By default we only display the first 3D item, except if it has the semantic flag \"3D\"\n                    var alreadyDisplay = false\n                    for (var i = 0; i < node.attributes.count; i++) {\n                        var attr = node.attributes.at(i)\n                        if (attr.isOutput && attr.desc.semantic !== \"image\")\n                            if (!alreadyDisplay || attr.desc.semantic == \"3d\") {\n                                if (workspaceView.viewIn3D(attr, mouse))\n                                        alreadyDisplay = true\n                            }\n                                \n                        }\n\n                    // Text viewer - open the first text output when the node has only text outputs\n                    if (node.hasTextOutput && !node.hasImageOutput && !node.hasSequenceOutput && !node.has3DOutput) {\n                        for (var j = 0; j < node.attributes.count; j++) {\n                            var textAttr = node.attributes.at(j)\n                            if (textAttr.isOutput && textAttr.isTextDisplayable) {\n                                workspaceView.viewInText(textAttr)\n                                break\n                            }\n                        }\n                    }\n                }\n\n                function viewIn2D(attribute, mouse) {\n                    settingsUILayout.showImageViewer = true\n                    workspaceView.mediaViewerTabIndex = 0\n                    workspaceView.viewer2D.tryLoadNode(attribute.node)\n                    workspaceView.viewer2D.setAttributeName(attribute.name)\n                }\n\n                function viewInText(attribute) {\n                    settingsUILayout.showTextViewer = true\n                    // Text Viewer is at index 1 when Image Viewer is also shown, else at index 0\n                    workspaceView.mediaViewerTabIndex = settingsUILayout.showImageViewer ? 1 : 0\n                    workspaceView.viewerText.source = Filepath.stringToUrl(attribute.value)\n                }\n\n                function viewIn3D(attribute, mouse) {\n\n                    if (!panel3dViewer || (!attribute.node.has3DOutput && !attribute.node.hasAttribute(\"useBoundingBox\"))) {\n                        return false\n                    }\n                    var loaded = panel3dViewer.viewer3D.view(attribute)\n\n                    // solo media if Control modifier was held\n                    if (loaded && mouse && mouse.modifiers & Qt.ControlModifier) {\n                        panel3dViewer.viewer3D.solo(attribute)\n                    }\n                    return loaded\n                }\n\n                function viewAttributeInViewer(mouse, attribute) {\n                    /* Display the current attribute in the corresponding viewer */\n\n                    if (attribute.is2dDisplayable) {\n                        workspaceView.viewIn2D(attribute, mouse)\n                    }\n\n                    else if (attribute.is3dDisplayable) {\n                            workspaceView.viewIn3D(attribute, mouse)\n                    }\n\n                    else if (attribute.isTextDisplayable) {\n                        workspaceView.viewInText(attribute)\n                    }\n\n                }\n            }\n\n            MSplitView {\n                id: bottomContainer\n                orientation: Qt.Horizontal\n                visible: settingsUILayout.showGraphEditor\n                SplitView.preferredHeight: 300\n                SplitView.minimumHeight: 80\n\n                TabPanel {\n                    id: graphEditorPanel\n                    SplitView.fillWidth: true\n                    SplitView.minimumWidth: 350\n\n                    padding: 4\n                    tabs: [\"Graph Editor\", \"Task Manager\", \"Script Editor\"]\n\n                    headerBar: RowLayout {\n                        MaterialToolButton {\n                            text: MaterialIcons.sync\n                            ToolTip.text: \"Refresh Nodes Status\"\n                            ToolTip.visible: hovered\n                            font.pointSize: 11\n                            padding: 2\n                            onClicked: {\n                                updatingStatus = true\n                                _currentScene.forceNodesStatusUpdate()\n                                updatingStatus = false\n                            }\n                            property bool updatingStatus: false\n                            enabled: !updatingStatus\n                        }\n                        MaterialToolButton {\n                            text: MaterialIcons.more_vert\n                            font.pointSize: 11\n                            padding: 2\n                            onClicked: graphEditorMenu.open()\n                            checkable: true\n                            checked: graphEditorMenu.visible\n                            Menu {\n                                id: graphEditorMenu\n                                y: parent.height\n                                x: -width + parent.width\n                                MenuItem {\n                                    text: \"Clear Pending Status\"\n                                    enabled: _currentScene ? !_currentScene.computingLocally : false\n                                    onTriggered: _currentScene.graph.clearSubmittedNodes(_currentScene.getSelectedNodes())\n                                }\n                                MenuItem {\n                                    text: \"Force Unlock Nodes\"\n                                    onTriggered: _currentScene.graph.forceUnlockNodes(_currentScene.getSelectedNodes())\n                                }\n\n                                Menu {\n                                    title: \"Auto Layout Depth\"\n\n                                    MenuItem {\n                                        id: autoLayoutMinimum\n                                        text: \"Minimum\"\n                                        checkable: true\n                                        checked: _currentScene.layout.depthMode === 0\n                                        ToolTip.text: \"Sets the Auto Layout Depth Mode to use Node's Minimum depth\"\n                                        ToolTip.visible: hovered\n                                        ToolTip.delay: 200\n                                        onToggled: {\n                                            if (checked) {\n                                                _currentScene.layout.depthMode = 0;\n                                                autoLayoutMaximum.checked = false;\n                                            }\n                                            // Prevents cases where the user unchecks the currently checked option\n                                            autoLayoutMinimum.checked = true;\n                                        }\n                                    }\n                                    MenuItem {\n                                        id: autoLayoutMaximum\n                                        text: \"Maximum\"\n                                        checkable: true\n                                        checked: _currentScene.layout.depthMode === 1\n                                        ToolTip.text: \"Sets the Auto Layout Depth Mode to use Node's Maximum depth\"\n                                        ToolTip.visible: hovered\n                                        ToolTip.delay: 200\n                                        onToggled: {\n                                            if (checked) {\n                                                _currentScene.layout.depthMode = 1;\n                                                autoLayoutMinimum.checked = false;\n                                            }\n                                            // Prevents cases where the user unchecks the currently checked option\n                                            autoLayoutMaximum.checked = true;\n                                        }\n                                    }\n                                }\n\n                                Menu {\n                                    title: \"Refresh Nodes Method\"\n\n                                    MenuItem {\n                                    id: enableAutoRefresh\n                                    text: \"Enable Auto-Refresh\"\n                                    checkable: true\n                                    checked: _currentScene.filePollerRefresh === 0\n                                    ToolTip.text: \"Check every file's status periodically\"\n                                    ToolTip.visible: hovered\n                                    ToolTip.delay: 200\n                                    onToggled: {\n                                        if (checked) {\n                                            disableAutoRefresh.checked = false\n                                            minimalAutoRefresh.checked = false\n                                            _currentScene.filePollerRefreshChanged(0)\n                                        }\n                                        // Prevents cases where the user unchecks the currently checked option\n                                        enableAutoRefresh.checked = true\n                                    }\n                                }\n                                MenuItem {\n                                    id: disableAutoRefresh\n                                    text: \"Disable Auto-Refresh\"\n                                    checkable: true\n                                    checked: _currentScene.filePollerRefresh === 1\n                                    ToolTip.text: \"No file status will be checked\"\n                                    ToolTip.visible: hovered\n                                    ToolTip.delay: 200\n                                    onToggled: {\n                                        if (checked) {\n                                            enableAutoRefresh.checked = false\n                                            minimalAutoRefresh.checked = false\n                                            _currentScene.filePollerRefreshChanged(1)\n                                        }\n                                        // Prevents cases where the user unchecks the currently checked option\n                                        disableAutoRefresh.checked = true\n                                    }\n                                }\n                                MenuItem {\n                                    id: minimalAutoRefresh\n                                    text: \"Enable Minimal Auto-Refresh\"\n                                    checkable: true\n                                    checked: _currentScene.filePollerRefresh === 2\n                                    ToolTip.text: \"Check the file status of submitted or running chunks periodically\"\n                                    ToolTip.visible: hovered\n                                    ToolTip.delay: 200\n                                    onToggled: {\n                                        if (checked) {\n                                            disableAutoRefresh.checked = false\n                                            enableAutoRefresh.checked = false\n                                            _currentScene.filePollerRefreshChanged(2)\n                                        }\n                                        // Prevents cases where the user unchecks the currently checked option\n                                        minimalAutoRefresh.checked = true\n                                    }\n                                }\n                                }\n                            }\n                        }\n                    }\n\n                    GraphEditor {\n                        id: graphEditor\n                        anchors.fill: parent\n\n                        visible: graphEditorPanel.currentTab === 0\n\n                        uigraph: _currentScene\n                        nodeTypesModel: _nodeTypes\n\n                        onNodeDoubleClicked: function(mouse, node) {\n                            _currentScene.setActiveNode(node);\n                            workspaceView.viewNode(node, mouse);\n                        }\n                        onComputeRequest: function(nodes) {\n                            _currentScene.forceNodesStatusUpdate();\n                            computeManager.compute(nodes)\n                        }\n                        onSubmitRequest: function(nodes) {\n                            _currentScene.forceNodesStatusUpdate();\n                            computeManager.submit(nodes)\n                        }\n                        onFilesDropped: function(drop, mousePosition) {\n                            var filesByType = _currentScene.getFilesByTypeFromDrop(drop.urls)\n                            if (filesByType[\"meshroomScenes\"].length == 1) {\n                                ensureSaved(function() {\n                                    if (_currentScene.handleFilesUrl(filesByType, null, mousePosition)) {\n                                        MeshroomApp.addRecentProjectFile(filesByType[\"meshroomScenes\"][0])\n                                    }\n                                })\n                            } else {\n                                _currentScene.handleFilesUrl(filesByType, null, mousePosition)\n                            }\n                        }\n                    }\n\n                    TaskManager {\n                        id: taskManager\n                        anchors.fill: parent\n\n                        visible: graphEditorPanel.currentTab === 1\n\n                        uigraph: _currentScene\n                        taskManager: _currentScene ? _currentScene.taskManager : null\n                    }\n\n                    ScriptEditor {\n                        id: scriptEditor\n                        anchors.fill: parent\n                        rootApplication: root\n\n                        visible: graphEditorPanel.currentTab === 2\n                    }\n                }\n\n                NodeEditor {\n                    id: nodeEditor\n                    SplitView.preferredWidth: 500\n                    SplitView.minimumWidth: 350\n\n                    node: _currentScene ? _currentScene.selectedNode : null\n                    property bool computing: _currentScene ? _currentScene.computing : false       \n                    property var currentAttributes: []\n\n                    // Make NodeEditor readOnly when computing\n                    readOnly: node ? node.locked : false\n\n                    onUpgradeRequest: {\n                        var n = _currentScene.upgradeNode(node)\n                        _currentScene.selectedNode = n\n                    }                   \n\n                    onInAttributeClicked: function(srcItem, mouse, inAttributes) {                        \n                        _handleNavButtonClick(srcItem, mouse, inAttributes)                        \n                    }\n\n                    onOutAttributeClicked: function(srcItem, mouse, outAttributes) {\n                        _handleNavButtonClick(srcItem, mouse, outAttributes)\n                    }\n\n                    // NavButtonContextMenu\n                    Menu {\n                        id: navButtonContextMenu\n\n                        Repeater {\n                            model: nodeEditor.currentAttributes\n\n                            delegate: MenuItem {\n\n                                contentItem: Text {\n                                    text: `${modelData.node.label}.${modelData.label}`\n                                    elide: Text.ElideLeft\n                                    color: Colors.sysPalette.text\n                                }\n                                \n                                onTriggered: {\n                                    nodeEditor._selectNodesFromAttributes([nodeEditor.currentAttributes[index]])\n                                }\n                            }\n                        }\n\n                    }\n\n                    function _selectNodesFromAttributes(attributes) {\n                        /*\n                            Retrieve the nodes from given attributes, and select its \n                        */\n\n                        if ( !attributes || attributes.length == 0) { return }\n\n                        graphEditor.uigraph.clearNodeSelection()\n                        \n                        const nodes = attributes.map( attr => attr.node)\n\n                        if (attributes.length == 1) {\n                            _currentScene.selectedNode = attributes[0].node\n                        }\n                        graphEditor.uigraph.selectNodes(nodes)\n                    } \n\n                    function _openLinkAttributesContextMenu(srcItem, mouse, attributes) {\n                        nodeEditor.currentAttributes = attributes\n                        const srcGlobal = srcItem.mapToGlobal(0, 0)\n                        const nodeEditorGlobal = nodeEditor.mapToGlobal(0, 0)\n                        navButtonContextMenu.x = srcGlobal.x - nodeEditorGlobal.x\n                        navButtonContextMenu.y = srcGlobal.y - nodeEditorGlobal.y - 14 // TODO: Couldn't found a way to avoid padding in position. 14 = navButtonOut.paddingTop * 2\n                        navButtonContextMenu.open()\n                    }\n\n                    function _handleNavButtonClick(srcItem, mouse, attributes) {\n\n                        if (mouse.button === Qt.RightButton) {\n                            nodeEditor._openLinkAttributesContextMenu(srcItem, mouse, attributes)\n                            return\n                        }\n\n                        nodeEditor._selectNodesFromAttributes(attributes)\n\n                        if (mouse.button === Qt.MiddleButton) {\n                            graphEditor.fit()\n                        }\n                    }\n\n\n                    onShowAttributeInViewer: function(attribute) {\n                        workspaceView.viewAttributeInViewer(null, attribute)\n                    }\n\n                    onAttributeDoubleClicked: function(mouse, attribute) {\n                        workspaceView.viewAttributeInViewer(mouse, attribute)                        \n                    }\n                    \n                }\n            }\n        }\n    }\n\n}"
  },
  {
    "path": "meshroom/ui/qml/Charts/ChartViewCheckBox.qml",
    "content": "import QtQuick\nimport QtQuick.Controls\n\n/**\n * A custom CheckBox designed to be used in ChartView's legend.\n */\n\nCheckBox {\n    id: root\n\n    property color color\n\n    leftPadding: 0\n    font.pointSize: 8\n\n    indicator: Rectangle {\n        width: 11\n        height: width\n        border.width: 1\n        border.color: root.color\n        color: \"transparent\"\n        anchors.verticalCenter: parent.verticalCenter\n\n        Rectangle {\n            anchors.fill: parent\n            anchors.margins: parent.border.width + 1\n            visible: parent.parent.checkState != Qt.Unchecked\n            anchors.topMargin: parent.parent.checkState === Qt.PartiallyChecked ? 5 : 2\n            anchors.bottomMargin: anchors.topMargin\n            color: parent.border.color\n            anchors.centerIn: parent\n        }\n    }\n}\n"
  },
  {
    "path": "meshroom/ui/qml/Charts/ChartViewLegend.qml",
    "content": "import QtQuick\nimport QtQuick.Controls\nimport QtCharts\n\n/**\n * ChartViewLegend is an interactive legend component for ChartViews.\n * It provides a CheckBox for each series that can control its visibility,\n * and highlight on hovering.\n */\n\nFlow {\n    id: root\n\n    // The ChartView to create the legend for\n    property ChartView chartView\n    // Currently hovered series\n    property var hoveredSeries: null\n\n   readonly property ButtonGroup buttonGroup: ButtonGroup {\n        id: legendGroup\n        exclusive: false\n    }\n\n    /// Shortcut function to clear legend\n    function clear() {\n        seriesModel.clear()\n    }\n\n    // Update internal ListModel when ChartView's series change\n    Connections {\n        target: chartView\n        function onSeriesAdded(series) {\n            seriesModel.append({\"series\": series})\n        }\n        function onSeriesRemoved(series) {\n            for (var i = 0; i < seriesModel.count; ++i) {\n                if (seriesModel.get(i)[\"series\"] === series) {\n                    seriesModel.remove(i)\n                    return\n                }\n            }\n        }\n    }\n\n    onChartViewChanged: {\n        clear()\n        for (var i = 0; i < chartView.count; ++i)\n            seriesModel.append({\"series\": chartView.series(i)})\n    }\n\n    Repeater {\n        // ChartView series cannot be accessed directly as a model.\n        // Use an intermediate ListModel populated with those series.\n        model: ListModel {\n            id: seriesModel\n        }\n\n        ChartViewCheckBox {\n            ButtonGroup.group: legendGroup\n\n            checked: series.visible\n            text: series.name\n            color: series.color\n\n            onHoveredChanged: {\n                if (hovered && series.visible)\n                    root.hoveredSeries = series\n                else\n                    root.hoveredSeries = null\n            }\n\n            // Hovered serie properties override\n            states: [\n                State {\n                    when: series && root.hoveredSeries === series\n                    PropertyChanges { target: series; width: 5.0 }\n                },\n                State {\n                    when: series && root.hoveredSeries && root.hoveredSeries !== series\n                    PropertyChanges { target: series; width: 0.2 }\n                }\n            ]\n\n            MouseArea {\n                anchors.fill: parent\n                onClicked: function(mouse) {\n                    if (mouse.modifiers & Qt.ControlModifier)\n                        root.soloSeries(index)\n                    else\n                        series.visible = !series.visible\n                }\n            }\n        }\n    }\n\n    /// Hide all series but the one at index 'idx'\n    function soloSeries(idx) {\n        for (var i = 0; i < seriesModel.count; i++) {\n            chartView.series(i).visible = false\n        }\n        chartView.series(idx).visible = true\n    }\n}\n"
  },
  {
    "path": "meshroom/ui/qml/Charts/InteractiveChartView.qml",
    "content": "import QtQuick\nimport QtQuick.Layouts\nimport QtCharts\n\nChartView {\n    id: root\n    antialiasing: true\n\n    Rectangle {\n        id: plotZone\n        x: root.plotArea.x\n        y: root.plotArea.y\n        width: root.plotArea.width\n        height: root.plotArea.height\n        color: \"transparent\"\n\n        MouseArea {\n            anchors.fill: parent\n\n            property double degreeToScale: 1.0 / 120.0 // Default mouse scroll is 15 degree\n            acceptedButtons: Qt.LeftButton | Qt.RightButton | Qt.MiddleButton\n            onClicked: {\n                root.zoomReset()\n            }\n        }\n    }\n}\n"
  },
  {
    "path": "meshroom/ui/qml/Charts/qmldir",
    "content": "module Charts\n\nChartViewLegend 1.0 ChartViewLegend.qml\nChartViewCheckBox 1.0 ChartViewCheckBox.qml\nInteractiveChartView 1.0 InteractiveChartView.qml\n"
  },
  {
    "path": "meshroom/ui/qml/Controls/ColorChart.qml",
    "content": "import QtQuick\nimport QtQuick.Controls\n\nimport Utils 1.0\n\n/**\n * ColorChart is a color picker based on a set of predefined colors.\n * It takes the form of a ToolButton that pops-up its palette when pressed.\n */\n\nToolButton {\n    id: root\n\n    property var colors: [\"red\", \"green\", \"blue\"]\n    property int currentIndex: 0\n\n    signal colorPicked(var colorIndex)\n\n    background: Rectangle {\n        color: root.colors[root.currentIndex]\n        border.width: hovered ? 1 : 0\n        border.color: Colors.sysPalette.midlight\n    }\n\n    onPressed: palettePopup.open()\n\n    // Popup for the color palette\n    Popup {\n        id: palettePopup\n\n        padding: 4\n        // Content width is missing side padding (hence the + padding*2)\n        implicitWidth: colorChart.contentItem.width + padding * 2\n\n        // Center the current color\n        y: -(root.height - padding) / 2\n        x: -colorChart.currentItem.x - padding\n\n        // Colors palette\n        ListView {\n            id: colorChart\n            implicitHeight: contentItem.childrenRect.height\n            implicitWidth: contentWidth\n            orientation: ListView.Horizontal\n            spacing: 2\n            currentIndex: root.currentIndex\n            model: root.colors\n            // Display each color as a ToolButton with a custom background\n            delegate: ToolButton {\n                padding: 0\n                width: root.width\n                height: root.height\n                background: Rectangle {\n                    color: modelData\n                    // Display border of current/selected item\n                    border.width: hovered || index === colorChart.currentIndex ? 1 : 0\n                    border.color: Colors.sysPalette.midlight\n                }\n\n                onClicked: {\n                    colorPicked(index)\n                    palettePopup.close()\n                }\n            }\n        }\n    }\n}\n"
  },
  {
    "path": "meshroom/ui/qml/Controls/ColorSelector.qml",
    "content": "import QtQuick\nimport QtQuick.Controls\n\nimport Utils 1.0\nimport MaterialIcons 2.2\n\n/**\n * ColorSelector is a color picker based on a set of predefined colors.\n * It takes the form of a ToolButton that pops-up its palette when pressed.\n */\nMaterialToolButton {\n    id: root\n\n    text: MaterialIcons.palette\n\n    // Internal property holding when the popup remains visible and when is it toggled off\n    property var isVisible: false\n\n    property var colors: [\n                            \"#E35C03\",\n                            \"#FFAD7D\",\n                            \"#D0AE22\",\n                            \"#C9C770\",\n                            \"#3D6953\",\n                            \"#79C62F\",\n                            \"#02627E\",\n                            \"#2CB9CC\",\n                            \"#1453E6\",\n                            \"#507DD0\",\n                            \"#4D3E5C\",\n                            \"#A252BD\",\n                            \"#B61518\",\n                            \"#C16162\",\n                        ]\n\n    // When a color gets selected/chosen\n    signal colorSelected(var color)\n\n    // Toggles the visibility of the popup\n    onPressed: toggle()\n\n    function toggle() {\n        /*\n         * Toggles the visibility of the color palette.\n         */\n        if (!isVisible) {\n            palettePopup.open()\n            isVisible = true\n        }\n        else {\n            palettePopup.close()\n            isVisible = false\n        }\n    }\n\n    // Popup for the color palette\n    Popup {\n        id: palettePopup\n\n        // The popup will not get closed unless explicitly closed\n        closePolicy: Popup.NoAutoClose\n\n        // Bounds\n        padding: 4\n        width: (root.height * 4) + (padding * 4)\n\n        // Center the current color on the tool button\n        y: -height\n        x: -width / 2 + (root.width + padding) / 2\n\n        // Layout of the Colors\n        Grid {\n            // Allow only 4 columns and all the colors can be adjusted in row multiples of 4\n            columns: 4\n\n            spacing: 2\n            anchors.centerIn: parent\n\n            // Default -- Reset Colour button\n            ToolButton {\n                id: defaultButton\n                padding: 0\n                width: root.height\n                height: root.height\n\n                // Emit no color so the graph sets None as the color of the Node\n                onClicked: {\n                    root.colorSelected(\"\")\n                }\n\n                background: Rectangle {\n                    color: \"#FFFFFF\"\n                    // display border of current/selected item\n                    border.width: defaultButton.hovered ? 1 : 0\n                    border.color: \"#000000\"\n\n                    // Another Rectangle\n                    Rectangle {\n                        color: \"#FF0000\"\n                        width: parent.width + 8\n                        height: 2\n                        anchors.centerIn: parent\n                        rotation: 135   // Diagonally creating a Red line from bottom left to top right\n                    }\n                }\n            }\n\n            // Colors palette\n            Repeater {\n                model: root.colors\n                // display each color as a ToolButton with a custom background\n                delegate: ToolButton {\n                    padding: 0\n                    width: root.height\n                    height: root.height\n\n                    // Emit the model data as the color to update\n                    onClicked: {\n                        colorSelected(modelData)\n                    }\n\n                    // Model color as the background of the button\n                    background: Rectangle {\n                        color: modelData\n                        // display border of current/selected item\n                        border.width: hovered ? 1 : 0\n                        border.color: \"#000000\"\n                    }\n                }\n            }\n        }\n    }\n}\n"
  },
  {
    "path": "meshroom/ui/qml/Controls/DelegateSelectionBox.qml",
    "content": "import QtQuick\nimport Meshroom.Helpers\n\n/*\nA SelectionBox that can be used to select delegates in a model instantiator (Repeater, ListView...).\nInteresection test is done in the coordinate system of the container Item, using delegate's bounding boxes.\nThe list of selected indices is emitted when the selection ends.\n*/\n\nSelectionBox {\n    id: root\n\n    // The Item instantiating the delegates.\n    property Item modelInstantiator\n    // The Item containing the delegates (used for coordinate mapping).\n    property Item container\n    // Emitted when the selection has ended, with the list of selected indices and modifiers.\n    signal delegateSelectionEnded(list<int> indices, int modifiers)\n\n    onSelectionEnded: function(selectionRect, modifiers) {\n        let selectedIndices = [];\n        const mappedSelectionRect = mapToItem(container, selectionRect)\n        for (var i = 0; i < modelInstantiator.count; ++i) {\n            const delegate = modelInstantiator.getItemAt(i)\n            if (!delegate)\n                continue\n            // For backdrop nodes, only select when the selection rect intersects the titlebar.\n            const delegateRect = Qt.rect(delegate.x, delegate.y, delegate.width, delegate.isBackdropNode ? delegate.headerHeight : delegate.height)\n            if (Geom2D.rectRectIntersect(mappedSelectionRect, delegateRect)) {\n                selectedIndices.push(i)\n\n                if (delegate.isBackdropNode) {\n                    let children = delegate.getChildrenIndices(true)\n                    for (var child = 0; child < children.length; ++child) {\n                        if (selectedIndices.indexOf(children[child]) === -1) {\n                            selectedIndices.push(children[child])\n                        }\n                    }\n                }\n            }\n        }\n        delegateSelectionEnded(selectedIndices, modifiers)\n    }\n}\n"
  },
  {
    "path": "meshroom/ui/qml/Controls/DelegateSelectionLine.qml",
    "content": "import QtQuick\nimport Meshroom.Helpers\n\n/*\nA SelectionLine that can be used to select delegates in a model instantiator (Repeater, ListView...).\nInteresection test is done in the coordinate system of the container Item, using delegate's bounding boxes.\nThe list of selected indices is emitted when the selection ends.\n*/\n\nSelectionLine {\n    id: root\n\n    // The Item instantiating the delegates.\n    property Item modelInstantiator\n    // The Item containing the delegates (used for coordinate mapping).\n    property Item container\n    // Emitted when the selection has ended, with the list of selected indices and modifiers.\n    signal delegateSelectionEnded(list<int> indices, int modifiers)\n\n    onSelectionEnded: function(selectionP1, selectionP2, modifiers) {\n        let selectedIndices = [];\n        const mappedP1 = mapToItem(container, selectionP1);\n        const mappedP2 = mapToItem(container, selectionP2);\n        for (var i = 0; i < modelInstantiator.count; ++i) {\n            const delegate = modelInstantiator.itemAt(i);\n            if (delegate.intersectsSegment(mappedP1, mappedP2)) {\n                selectedIndices.push(i);\n            }\n        }\n        delegateSelectionEnded(selectedIndices, modifiers);\n    }\n}\n"
  },
  {
    "path": "meshroom/ui/qml/Controls/DirectionalLightPane.qml",
    "content": "import QtQuick\nimport QtQuick.Controls\nimport QtQuick.Layouts\nimport QtQuick.Shapes\nimport MaterialIcons 2.2\nimport Utils 1.0\n\n/**\n* Directional Light Pane\n*\n* @biref A small pane to control a directional light with a 2d ball controller.\n*\n* @param lightYawValue - directional light yaw (degrees)\n* @param lightPitchValue - directional light pitch (degrees)\n*/\nFloatingPane {\n    id: root\n\n    // yaw and pitch properties\n    property double lightYawValue: 0\n    property double lightPitchValue: 0\n\n    // 2d controller display size properties\n    readonly property real controllerSize: 80\n    readonly property real controllerRadius: controllerSize * 0.5\n\n    function reset() {\n        lightYawValue = 0;\n        lightPitchValue = 0;\n    }\n\n    // update 2d controller if yaw value changed\n    onLightYawValueChanged: { lightBallController.update() }\n\n    // update 2d controller if pitch value changed\n    onLightPitchValueChanged: { lightBallController.update() }\n\n    // pane properties\n    anchors.margins: 0\n    padding: 5\n\n    ColumnLayout {\n        anchors.fill: parent\n        spacing: 5\n\n        // header\n        RowLayout {\n            // pane title\n            Label {\n                text: \"Directional Light\"\n                font.bold: true\n                Layout.fillWidth: true\n            }\n\n            // minimize or maximize button\n            MaterialToolButton {\n                id: bodyButton\n                text: lightPaneBody.visible ? MaterialIcons.arrow_drop_down : MaterialIcons.arrow_drop_up\n                font.pointSize: 10\n                ToolTip.text: lightPaneBody.visible ? \"Minimize\" : \"Maximize\"\n                onClicked: { lightPaneBody.visible = !lightPaneBody.visible }\n            }\n\n            // reset button\n            MaterialToolButton {\n                id: resetButton\n                text: MaterialIcons.refresh\n                font.pointSize: 10\n                ToolTip.text: \"Reset\"\n                onClicked: reset()\n            }\n        }\n\n        // body\n        RowLayout {\n            id: lightPaneBody\n            spacing: 10\n\n            // light parameters\n            GridLayout {\n                columns: 3\n                rowSpacing: 2\n                columnSpacing: 8\n                Layout.alignment: Qt.AlignBottom\n\n                // light yaw\n                Label {\n                    text: \"Yaw\"\n                }\n                TextField {\n                    id: lightYawTF\n                    text: lightYawValue.toFixed(2)\n                    selectByMouse: true\n                    horizontalAlignment: TextInput.AlignRight\n                    validator: doubleDegreeValidator\n                    onEditingFinished: { lightYawValue = lightYawTF.text }\n                    ToolTip.text: \"Light yaw (degrees).\"\n                    ToolTip.visible: hovered\n                    Layout.preferredWidth: textMetricsDegreeValue.width\n                }\n                Label {\n                    text: \"°\"\n                }\n\n                // light pitch\n                Label {\n                    text: \"Pitch\"\n                }\n                TextField {\n                    id: lightPitchTF\n                    text: lightPitchValue.toFixed(2)\n                    selectByMouse: true\n                    horizontalAlignment: TextInput.AlignRight\n                    validator: doubleDegreeValidator\n                    onEditingFinished: { lightPitchValue = lightPitchTF.text }\n                    ToolTip.text: \"Light pitch (degrees).\"\n                    ToolTip.visible: hovered\n                    Layout.preferredWidth: textMetricsDegreeValue.width\n                }\n                Label {\n                    text: \"°\"\n                }\n            }\n\n            // directional light ball controller\n            Rectangle {\n                id: lightBallController\n                anchors.margins: 0\n                width: controllerSize\n                height: controllerSize\n                radius: 180 // circle\n                color: \"#FF000000\"\n                Layout.rightMargin: 5\n                Layout.leftMargin: 5\n                Layout.bottomMargin: 5\n\n                function update() {\n                    // get point from light yaw and pitch\n                    var y = (lightPitchValue / 90 * controllerRadius)\n                    var xMax = Math.sqrt(controllerRadius * controllerRadius - y * y) // get sphere maximum x coordinate\n                    var x = (lightYawValue / 90 * xMax)\n\n                    // get angle and distance\n                    var angleRad = Math.atan2(y, x)\n                    var distance = Math.sqrt(x * x + y * y)\n\n                    // avoid controller overflow  \n                    if(distance > controllerRadius)\n                    {\n                        x = controllerRadius * Math.cos(angleRad)\n                        y = controllerRadius * Math.sin(angleRad)\n                    }\n\n                    // update light point\n                    lightPoint.x = lightPoint.startOffset + x\n                    lightPoint.y = lightPoint.startOffset + y\n                }\n\n               \n                // light ball controller shapes\n                Shape {\n                    anchors.centerIn: parent\n                    width: parent.width\n                    height: parent.height\n\n                    // ball shape\n                    ShapePath {\n                        strokeWidth: 0\n\n                        // shade gradient\n                        fillGradient: RadialGradient {\n                            centerX: lightPoint.x + lightPoint.radius\n                            centerY: lightPoint.y + lightPoint.radius\n                            centerRadius: controllerSize\n                            focalX: (lightPoint.x - lightPoint.startOffset) * 0.75 + lightPoint.startOffset + lightPoint.radius\n                            focalY: (lightPoint.y - lightPoint.startOffset) * 0.75 + lightPoint.startOffset + lightPoint.radius\n                            focalRadius: 2 \n                            GradientStop { position: 0.00; color: \"#FFCCCCCC\" }\n                            GradientStop { position: 0.05; color: \"#FFAAAAAA\" }\n                            GradientStop { position: 0.50; color: \"#FF0C0C0C\" }\n                        }\n\n                        // ball circle path\n                        PathRectangle {\n                            x: 0\n                            y: 0\n                            width: controllerSize\n                            height: controllerSize\n                            radius: controllerSize * 0.5 // circle shape\n                        }\n                    }\n\n                    // light point shape\n                    ShapePath {\n                        strokeWidth: 0\n\n                        // glow gradient\n                        fillGradient: RadialGradient {\n                            centerX: lightPoint.x + centerRadius\n                            centerY: lightPoint.y + centerRadius\n                            centerRadius: lightPoint.radius\n                            focalX: centerX\n                            focalY: centerY\n                            GradientStop { position: 0.4; color: \"#FFFFFFFF\" }\n                            GradientStop { position: 0.75; color: \"#33FFFFFF\" }\n                            GradientStop { position: 1.0; color: \"#00FFFFFF\" }\n                        }\n\n                        // point circle path\n                        PathRectangle {\n                            id: lightPoint\n                            readonly property double startOffset : (lightBallController.width - width) * 0.5 \n                            x: startOffset\n                            y: startOffset\n                            width: controllerRadius * 0.4\n                            height: width\n                            radius: width * 0.5  // circle shape\n                        }\n                    }\n                }\n\n                MouseArea {\n                    id: lightMouseArea\n                    anchors.centerIn: parent\n                    anchors.fill: parent\n\n                    onPositionChanged: {\n                        // get coordinates from center\n                        var x = mouseX - controllerRadius\n                        var y = mouseY - controllerRadius\n\n                        // get distance to center\n                        var distance = Math.sqrt(x * x + y * y)\n\n                        // avoid controller overflow  \n                        if(distance > controllerRadius)\n                        {\n                            var angleRad = Math.atan2(y, x)\n                            x = controllerRadius * Math.cos(angleRad)\n                            y = controllerRadius * Math.sin(angleRad)\n                        }\n\n                        // get sphere maximum x coordinate\n                        var xMax = Math.sqrt(controllerRadius * controllerRadius - y * y)\n\n                        // update light yaw and pitch\n                        lightYawValue = (xMax > 0) ? ((x / xMax) * 90) : 0 // between -90 and 90 degrees\n                        lightPitchValue = (y / controllerRadius) * 90      // between -90 and 90 degrees\n                    }\n                }\n            }\n        }\n    }\n\n    DoubleValidator {\n        id: doubleDegreeValidator\n        locale: 'C' // use '.' decimal separator disregarding of the system locale\n        bottom: -90.0\n        top: 90.0\n    }\n\n    TextMetrics {\n        id: textMetricsDegreeValue\n        font: lightYawTF.font\n        text: \"12.3456\" \n    }\n}"
  },
  {
    "path": "meshroom/ui/qml/Controls/ExifOrientedViewer.qml",
    "content": "import QtQuick\n\nimport Utils 1.0\n\n/**\n * Loader with a predefined transform to orient its content according to the provided Exif tag.\n * Useful when displaying images and overlaid information in the Viewer2D.\n * \n * Usage:\n * - set the orientationTag property to specify Exif orientation tag.\n * - set the xOrigin/yOrigin properties to specify the transform origin.\n */\nLoader {\n    property var orientationTag: undefined\n\n    property real xOrigin: 0\n    property real yOrigin: 0\n\n    transform: [\n        Rotation {\n            angle: ExifOrientation.rotation(orientationTag)\n            origin.x: xOrigin\n            origin.y: yOrigin\n        },\n        Scale {\n            xScale: ExifOrientation.xscale(orientationTag)\n            origin.x: xOrigin\n            origin.y: yOrigin\n        }\n    ]\n}\n"
  },
  {
    "path": "meshroom/ui/qml/Controls/ExpandableGroup.qml",
    "content": "import QtQuick\nimport QtQuick.Controls\nimport QtQuick.Layouts\n\nimport MaterialIcons 2.2\n\n/**\n * A custom GroupBox with predefined header that can be hidden and expanded.\n */\nGroupBox {\n    id: root\n\n    title: \"\"\n    property int sidePadding: 6\n    property alias labelBackground: labelBg\n    property alias toolBarContent: toolBar.data\n    property bool expanded: expandButton.checked\n\n    padding: 2\n    leftPadding: sidePadding\n    rightPadding: sidePadding\n    topPadding: label.height + padding\n    background: Item {}\n\n    MouseArea {\n        parent: paneLabel\n        anchors.fill: parent\n        onClicked: function(mouse) {\n            expandButton.checked = !expandButton.checked\n        }\n    }\n\n    label: Pane {\n        id: paneLabel\n        padding: 2\n        width: root.width\n\n        background: Rectangle {\n            id: labelBg\n            color: palette.base\n            opacity: 0.8\n        }\n\n        RowLayout {\n            width: parent.width\n            Label {\n                text: root.title\n                Layout.fillWidth: true\n                elide: Text.ElideRight\n                padding: 3\n                font.bold: true\n                font.pointSize: 8\n            }\n            RowLayout {\n                id: toolBar\n                height: parent.height\n\n                MaterialToolButton {\n                    id: expandButton\n                    ToolTip.text: \"Expand More\"\n                    text: MaterialIcons.expand_more\n                    font.pointSize: 10\n                    implicitHeight: parent.height\n                    checkable: true\n                    checked: false\n\n                    onCheckedChanged: {\n                        if (checked) {\n                            ToolTip.text = \"Expand Less\"\n                            text = MaterialIcons.expand_less\n                        } else {\n                            ToolTip.text = \"Expand More\"\n                            text = MaterialIcons.expand_more\n                        }\n                    }\n                }\n            }\n        }\n    }\n}\n"
  },
  {
    "path": "meshroom/ui/qml/Controls/FilterComboBox.qml",
    "content": "import QtQuick\nimport QtQuick.Controls\nimport QtQuick.Layouts\nimport Utils 1.0\n\nimport MaterialIcons\n\n/**\n* ComboBox with filtering capabilities and support for custom values (i.e: outside the source model).\n*/\n\nComboBox {\n    id: root\n\n    // Model to populate the combobox.\n    required property var sourceModel\n    // Input value to use as the current combobox value.\n    property var inputValue\n    // The text to filter the combobox model when the choices are displayed.\n    property alias filterText: filterTextArea.text\n    // Whether the current input value is within the source model.\n    readonly property bool validValue: sourceModel.includes(inputValue)\n\n\n    QtObject {\n        id: m\n        readonly property int delegateModelCount: root.delegateModel.count\n\n        // Ensure the highlighted index is always within the range of delegates whenever the\n        // combobox model changes, for combobox validation to always considers a valid item.\n        onDelegateModelCountChanged: {\n            if(delegateModelCount > 0 && root.highlightedIndex >= delegateModelCount) {\n                while(root.highlightedIndex > 0 && root.highlightedIndex >= delegateModelCount) {\n                    // highlightIndex is read-only, this method has to be used to change it programmatically.\n                    root.decrementCurrentIndex();\n                }\n            }\n        }\n    }\n\n    signal editingFinished(var value)\n\n    function clearFilter() {\n        filterText = \"\";\n    }\n\n    // Re-computing current index when source values are set.\n    Component.onCompleted: _updateCurrentIndex()\n    onInputValueChanged: _updateCurrentIndex()\n    onModelChanged: _updateCurrentIndex()\n\n    function _updateCurrentIndex() {\n        currentIndex = find(inputValue);\n    }\n\n    displayText: inputValue\n\n    model: {\n        return sourceModel.filter(item => {\n            return item.toString().toLowerCase().includes(filterText.toLowerCase());\n        });\n    } \n\n    popup.onOpened: {\n        filterTextArea.forceActiveFocus();\n    }\n\n    popup.onClosed: clearFilter()\n\n    onActivated: (index) => {\n        const isValidEntry = model.length > 0;\n        if (!isValidEntry) {\n            return;\n        }\n        editingFinished(model[index]);\n    }\n\n    StateGroup {\n        id: filterState\n        // Override properties depending on filter text status.\n        states: [\n            State {\n                name: \"Invalid\"\n                when: m.delegateModelCount === 0\n                PropertyChanges {\n                    target: filterTextArea\n                    color: Colors.orange\n                    // Prevent ComboBox validation when there are no entries in the model.\n                    Keys.forwardTo: []\n                }\n            }\n        ]\n    }\n\n    popup.contentItem: ColumnLayout {\n        width: parent.width\n        spacing: 0\n\n        RowLayout {\n            Layout.fillWidth: true\n            spacing: 2\n\n            TextField {\n                id: filterTextArea\n                placeholderText: \"Type to filter...\"\n                Layout.fillWidth: true\n                leftPadding: 18\n                Keys.forwardTo: [root]\n\n                background: Item {\n                    MaterialLabel {\n                        anchors.verticalCenter: parent.verticalCenter\n                        anchors.left: parent.left\n                        anchors.leftMargin: 2\n                        text: MaterialIcons.search\n                    }\n                }\n            }\n\n            MaterialToolButton {\n                enabled: root.filterText !== \"\"\n                text: MaterialIcons.add_task\n                ToolTip.text: \"Force custom value\"\n                onClicked: {\n                    editingFinished(root.filterText);\n                    root.popup.close();\n                }\n            }\n        }\n\n        Rectangle {\n            height: 1\n            Layout.fillWidth: true\n            color: Colors.sysPalette.mid\n        }\n\n        ScrollView {\n            Layout.fillWidth: true\n            Layout.fillHeight: true\n            ScrollBar.horizontal.policy: ScrollBar.AlwaysOff\n\n            ListView {\n                implicitHeight: contentHeight\n                clip: true\n\n                model: root.delegateModel\n                highlightRangeMode: ListView.ApplyRange\n                currentIndex: root.highlightedIndex\n                ScrollBar.vertical: ScrollBar {}\n            }\n        }\n    }\n}\n"
  },
  {
    "path": "meshroom/ui/qml/Controls/FloatingPane.qml",
    "content": "import QtQuick\nimport QtQuick.Controls\nimport QtQuick.Layouts\n\n/**\n * FloatingPane provides a Pane with a slightly transparent default background\n * using palette.base as color. Useful to create floating toolbar/overlays.\n */\n\nPane {\n    id: root\n\n    property bool opaque: false\n    property int radius: 1\n\n    padding: 6\n    anchors.margins: 2\n    background: Rectangle {\n        color: root.palette.base\n        opacity: opaque ? 1.0 : 0.7\n        radius: root.radius\n    }\n}\n"
  },
  {
    "path": "meshroom/ui/qml/Controls/Group.qml",
    "content": "import QtQuick\nimport QtQuick.Controls\nimport QtQuick.Layouts\n\n/**\n * A custom GroupBox with predefined header.\n */\n\nGroupBox {\n    id: root\n\n    title: \"\"\n    property int sidePadding: 6\n    property alias labelBackground: labelBg\n    property alias toolBarContent: toolBar.data\n\n    padding: 2\n    leftPadding: sidePadding\n    rightPadding: sidePadding\n    topPadding: label.height + padding\n    background: Item {}\n\n    label: Pane {\n        padding: 2\n        width: root.width\n        background: Rectangle {\n            id: labelBg\n            color: palette.base\n            opacity: 0.8\n        }\n\n        RowLayout {\n            width: parent.width\n            Label {\n                text: root.title\n                Layout.fillWidth: true\n                elide: Text.ElideRight\n                padding: 3\n                font.bold: true\n                font.pointSize: 8\n            }\n            RowLayout {\n                id: toolBar\n                height: parent.height\n            }\n        }\n    }\n}\n"
  },
  {
    "path": "meshroom/ui/qml/Controls/IntSelector.qml",
    "content": "import QtQuick\nimport QtQuick.Controls\nimport QtQuick.Layouts\n\nimport MaterialIcons 2.2\n\n/*\n* IntSelector with arrows and a text input to select a number\n*/\n\nRow {\n    id: root\n\n    property string tooltipText: \"\"\n    property int value: 0\n    property var range: { \"min\" : 0, \"max\" : 0 }\n\n    Layout.alignment: Qt.AlignVCenter\n\n    spacing: 0\n    property bool displayButtons: previousIntButton.hovered || intInputMouseArea.containsMouse || nextIntButton.hovered\n    property real buttonsOpacity: displayButtons ? 1.0 : 0.0\n\n    MaterialToolButton {\n        id: previousIntButton\n\n        opacity: buttonsOpacity\n        width: 10\n        text: MaterialIcons.navigate_before\n        ToolTip.text: \"Previous\"\n\n        onClicked: {\n            if (value > range.min) {\n                value -= 1\n            }\n        }\n    }\n\n    TextInput {\n        id: intInput\n\n        ToolTip.text: tooltipText\n        ToolTip.visible: tooltipText && intInputMouseArea.containsMouse\n\n        width: intMetrics.width\n        height: previousIntButton.height\n\n        color: palette.text\n        horizontalAlignment: Text.AlignHCenter\n        verticalAlignment: Text.AlignVCenter\n        selectByMouse: true\n\n        text: value\n\n        onEditingFinished: {\n            // 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\n            value = parseInt(text)\n            value = Math.min(range.max, Math.max(range.min, parseInt(text)))\n            focus = false\n        }\n\n        MouseArea {\n            id: intInputMouseArea\n            anchors.fill: parent\n            hoverEnabled: true\n            acceptedButtons: Qt.NoButton\n            propagateComposedEvents: true\n        }\n    }\n\n    MaterialToolButton {\n        id: nextIntButton\n\n        width: 10\n        opacity: buttonsOpacity\n        text: MaterialIcons.navigate_next\n        ToolTip.text: \"Next\"\n\n        onClicked: {\n            if (value < range.max) {\n                value += 1\n            }\n        }\n    }\n\n    TextMetrics {\n        id: intMetrics\n\n        font: intInput.font\n        text: \"10000\"\n    }\n\n}"
  },
  {
    "path": "meshroom/ui/qml/Controls/KeyValue.qml",
    "content": "import QtQuick\nimport QtQuick.Controls\nimport QtQuick.Layouts\n\n/**\n * KeyValue allows to create a list of key/value, like a table.\n */\n\nRectangle {\n    property alias key: keyLabel.text\n    property alias value: valueText.text\n\n    color: activePalette.window\n\n    width: parent.width\n    height: childrenRect.height\n\n    RowLayout {\n        width: parent.width\n        Rectangle {\n            anchors.margins: 2\n            color: Qt.darker(activePalette.window, 1.1)\n            Layout.minimumWidth: 10.0 * Qt.application.font.pixelSize\n            Layout.maximumWidth: 15.0 * Qt.application.font.pixelSize\n            Layout.fillWidth: false\n            Layout.fillHeight: true\n            Label {\n                id: keyLabel\n                text: \"test\"\n                anchors.fill: parent\n                anchors.top: parent.top\n                topPadding: 4\n                leftPadding: 6\n                verticalAlignment: TextEdit.AlignTop\n                elide: Text.ElideRight\n            }\n        }\n        TextArea {\n            id: valueText\n            text: \"\"\n            anchors.margins: 2\n            Layout.fillWidth: true\n            wrapMode: Label.WrapAtWordBoundaryOrAnywhere\n            textFormat: TextEdit.PlainText\n\n            readOnly: true\n            selectByMouse: true\n            background: Rectangle {\n                anchors.fill: parent\n                color: Qt.darker(activePalette.window, 1.05)\n            }\n        }\n    }\n}\n"
  },
  {
    "path": "meshroom/ui/qml/Controls/MScrollBar.qml",
    "content": "import QtQuick\nimport QtQuick.Controls\n\n/**\n * MScrollBar is a custom scrollbar implementation.\n * It is a vertical scrollbar that can be used to scroll a ListView.\n */\n\nScrollBar {\n    id: root\n    policy: ScrollBar.AlwaysOn\n\n    visible: root.horizontal ? parent.contentWidth > parent.width : parent.contentHeight > parent.height\n    minimumSize: 0.1\n\n    Component.onCompleted: {\n        contentItem.color = Qt.lighter(palette.mid, 2)\n    }\n\n    onHoveredChanged: {\n        if (pressed) return\n        contentItem.color = hovered ? Qt.lighter(palette.mid, 3) : Qt.lighter(palette.mid, 2)\n    }\n\n    onPressedChanged: {\n        contentItem.color = pressed ? Qt.lighter(palette.mid, 4) : hovered ? Qt.lighter(palette.mid, 3) : Qt.lighter(palette.mid, 2)\n    }\n}"
  },
  {
    "path": "meshroom/ui/qml/Controls/MSplitView.qml",
    "content": "import QtQuick\nimport QtQuick.Controls\n\nSplitView {\n    id: splitView\n\n    handle: Rectangle {\n        id: handleDelegate\n        implicitWidth: 5\n        implicitHeight: 5\n        color: palette.window\n        property bool hovered: SplitHandle.hovered\n        property bool pressed: SplitHandle.pressed\n        Rectangle {\n            id: handleDisplay\n            anchors.centerIn: parent\n            property int handleSize: handleDelegate.pressed ? 3 : 1\n            width: splitView.orientation === Qt.Horizontal ? handleSize : handleDelegate.width\n            height: splitView.orientation === Qt.Vertical ? handleSize : handleDelegate.height\n            color: handleDelegate.hovered ? palette.highlight : palette.base\n        }\n    }\n}\n"
  },
  {
    "path": "meshroom/ui/qml/Controls/MessageDialog.qml",
    "content": "import QtQuick\nimport QtQuick.Controls\nimport QtQuick.Layouts\n\nimport MaterialIcons 2.2\n\nDialog {\n    id: root\n\n    property alias text: textLabel.text\n    property alias detailedText: detailedLabel.text\n    property alias helperText: helperLabel.text\n    property alias icon: iconLabel\n    property alias canCopy: copyButton.visible\n    property alias preset: presets.state\n    property alias content: contentComponent.sourceComponent\n    property alias textMetrics: textMetrics\n\n    default property alias children: layout.children\n\n    // The content of this MessageDialog as a string\n    readonly property string asString: titleLabel.text + \"\\n\\n\" + text + \"\\n\" + detailedText + \"\\n\" + helperText + \"\\n\"\n\n    /// Return the text content of this dialog as a simple string.\n    /// Used when copying the message in the system clipboard.\n    /// Can be overridden in components extending MessageDialog\n    function getAsString() {\n        return asString\n    }\n\n    x: parent.width / 2 - width / 2\n    y: parent.height / 2 - height / 2\n    modal: true\n\n    padding: 15\n    standardButtons: Dialog.Ok\n\n    header: Pane {\n        topPadding: 6\n        bottomPadding: 0\n        leftPadding: 8\n        rightPadding: leftPadding\n\n        background: Item {\n            // Hidden text edit to perform copy in clipboard\n            TextEdit {\n                id: textContent\n                visible: false\n            }\n        }\n\n        RowLayout {\n            // Icon\n            Label {\n                id: iconLabel\n                font.family: MaterialIcons.fontFamily\n                font.pointSize: 14\n                visible: text != \"\"\n            }\n\n            Label {\n                id: titleLabel\n                text: title + \" - \" + Qt.application.name + \" \" + Qt.application.version\n                font.bold: true\n            }\n            MaterialToolButton {\n                id: copyButton\n                text: MaterialIcons.content_copy\n                ToolTip.text: \"Copy Message to Clipboard\"\n                font.pointSize: 11\n                onClicked: {\n                    textContent.text = getAsString()\n                    textContent.selectAll(); textContent.copy()\n                }\n            }\n        }\n    }\n\n    contentItem: ColumnLayout {\n        id: layout\n        // Text\n        spacing: 12\n        Label {\n            id: textLabel\n            font.bold: true\n            visible: text != \"\"\n            onLinkActivated: function(link) { Qt.openUrlExternally(link) }\n\n            Layout.minimumWidth: 500\n            Layout.preferredWidth: titleLabel.width\n            wrapMode: Text.WordWrap\n        }\n        // Detailed text\n        Label {\n            id: detailedLabel\n            text: text\n            visible: text != \"\"\n            onLinkActivated: function(link) { Qt.openUrlExternally(link) }\n\n            Layout.minimumWidth: 500\n            Layout.preferredWidth: titleLabel.width\n            wrapMode: Text.WordWrap\n        }\n        // Additional helper text\n        Label {\n            id: helperLabel\n            visible: text != \"\"\n            onLinkActivated: function(link) { Qt.openUrlExternally(link) }\n\n            Layout.minimumWidth: 500\n            Layout.preferredWidth: titleLabel.width\n            wrapMode: Text.WordWrap\n        }\n\n        Loader {\n            id: contentComponent\n\n            Layout.fillWidth: true\n        }\n    }\n\n    TextMetrics {\n        id: textMetrics\n\n        text: \"A\"\n    }\n\n    StateGroup {\n        id: presets\n        states: [\n            State {\n                name: \"Info\"\n                PropertyChanges {\n                    target: iconLabel\n                    text: MaterialIcons.info\n                }\n            },\n            State {\n                name: \"Warning\"\n                PropertyChanges {\n                    target: iconLabel\n                    text: MaterialIcons.warning\n                    color: \"#FF9800\"\n                }\n            },\n            State {\n                name: \"Error\"\n                PropertyChanges {\n                    target: iconLabel\n                    text: MaterialIcons.error\n                    color: \"#F44336\"\n                }\n            }\n        ]\n    }\n}\n"
  },
  {
    "path": "meshroom/ui/qml/Controls/NodeActions.qml",
    "content": "import QtQuick\nimport QtQuick.Controls\nimport QtQuick.Layouts\n\nimport MaterialIcons 2.2\nimport Utils 1.0\n\nItem {\n    id: root\n    \n    // Settings\n    readonly property real headerOffset: 10   // Distance above the node in screen pixels\n    readonly property real _opacity: 0.9\n\n    // Objects passed from the graph editor\n    property var uigraph: null\n    property var draggable: null     // The draggable container from GraphEditor\n    property var nodeRepeater: null  // Reference to nodeRepeater to find delegates\n\n    // Signals\n    signal computeRequest(var node)      // Start local computation\n    signal stopComputeRequest(var node)  // Stop local computation\n    signal deleteDataRequest(var node)   // Delete node data\n    signal submitRequest(var node)       // Start external computation (submission on farm)\n    signal stopSubmitRequest(var node)   // Stop external computation (interrupt tasks on farm)\n    signal retrySubmitRequest(var node)  // Retry error tasks on farm\n    \n    SystemPalette { id: activePalette }\n\n    /**\n      * Get the node delegate\n      */\n    function nodeDelegate(node) {\n        if (!nodeRepeater) \n            return null\n        for (var i = 0; i < nodeRepeater.count; ++i) {\n            if (nodeRepeater.getItemAt(i).node === node)\n                return nodeRepeater.getItemAt(i)\n        }\n        return null\n    }\n\n    enum ButtonState {\n        DISABLED   = 0,\n        LAUNCHABLE = 1,\n        DELETABLE  = 2,\n        STOPPABLE  = 3\n    }\n\n    Rectangle {\n        id: actionHeader\n\n        readonly property bool hasSelectedNode: uigraph && uigraph.nodeSelection.selectedIndexes.length === 1\n        readonly property var selectedNode: hasSelectedNode ? uigraph.selectedNode : null\n        readonly property var selectedNodeDelegate: selectedNode ? root.nodeDelegate(selectedNode) : null\n\n        visible: selectedNodeDelegate !== null\n        color: \"transparent\"\n        width: actionItemsRow.width\n        height: actionItemsRow.height\n\n        // \n        // ===== Manage NodeActions position =====\n        // \n\n        // Prevents losing focus on the node when we click on buttons of the actionItems\n        MouseArea {\n            anchors.fill: parent\n            onPressed:       function(mouse) { mouse.accepted = true }\n            onReleased:      function(mouse) { mouse.accepted = true }\n            onClicked:       function(mouse) { mouse.accepted = true }\n            onDoubleClicked: function(mouse) { mouse.accepted = true }\n            hoverEnabled: false\n        }\n\n        function keepNodeActionOnWindow() {\n            if (x < 0) {\n                x = 0\n            }\n            if (y < 0) {\n                y = 0\n            }\n        }\n\n        // Update position\n        function updatePosition() {\n            if (width == 0 && height == 0) {  \n                actionItemsRow.visible = true  \n                return  \n            } else if (width == 0 || height == 0) {  \n                actionItemsRow.visible = false  \n                return  \n            }  \n            actionItemsRow.visible = true  \n\n            if (!selectedNodeDelegate || !draggable) return\n            // Calculate node position in screen coordinates\n            const nodeScreenX = selectedNodeDelegate.x * draggable.scale + draggable.x\n            const nodeScreenY = selectedNodeDelegate.y * draggable.scale + draggable.y\n            // Position header above the node (fixed offset in screen pixels)\n            x = nodeScreenX + (selectedNodeDelegate.width * draggable.scale - width) / 2\n            y = nodeScreenY - height - headerOffset\n            // keepNodeActionOnWindow()\n        }\n\n        onHeightChanged: {\n            actionHeader.updatePosition()\n        }\n\n        onWidthChanged: {\n            actionHeader.updatePosition()\n        }\n\n        // Update position when the user moves on the graph\n        Connections {\n            target: root.draggable\n            function onXChanged()     { Qt.callLater(actionHeader.updatePosition) }\n            function onYChanged()     { Qt.callLater(actionHeader.updatePosition) }\n            function onScaleChanged() { Qt.callLater(actionHeader.updatePosition) }\n        }\n\n        // Update position when nodes are moved\n        Connections {\n            target: actionHeader.selectedNodeDelegate\n            function onXChanged() { actionHeader.updatePosition() }\n            function onYChanged() { actionHeader.updatePosition() }\n            ignoreUnknownSignals: true\n        }\n\n        // \n        // ===== Manage buttons =====\n        // \n\n        property bool nodeIsLocked: false\n        property bool canComputeNode: false\n        property bool canStopNode: false\n        property bool canRestartNode: false  // Node can be restarted, locally or externally\n        property bool canSubmitNode: false\n        property bool nodeSubmitted: false\n        property bool canRetryNode: false    // Error tasks can be restarted for external node\n\n        property int computeButtonState: NodeActions.ButtonState.LAUNCHABLE\n        property string computeButtonIcon: {\n            switch (computeButtonState) {\n                case NodeActions.ButtonState.STOPPABLE: return MaterialIcons.cancel_schedule_send\n                default: return MaterialIcons.send\n            }\n        }\n        property string computeButtonTooltip: {\n            switch (computeButtonState) {\n                case NodeActions.ButtonState.STOPPABLE: return \"Stop Compute\"\n                default: return \"Start Compute\"\n            }\n        }\n\n        property int submitButtonState: NodeActions.ButtonState.LAUNCHABLE\n        property string submitButtonIcon: {\n            switch (submitButtonState) {\n                case NodeActions.ButtonState.STOPPABLE: return MaterialIcons.paragliding\n                default: return MaterialIcons.rocket_launch\n            }\n        }\n        property string submitButtonTooltip: {\n            switch (submitButtonState) {\n                case NodeActions.ButtonState.STOPPABLE: return \"Interrupt Job on Render Farm\"\n                default: return \"Submit on Render Farm\"\n            }\n        }\n\n        function getComputeButtonState(node) {\n            if (actionHeader.canStopNode)\n                return NodeActions.ButtonState.STOPPABLE\n            if (!actionHeader.nodeIsLocked && node.globalStatus == \"SUCCESS\")\n                return NodeActions.ButtonState.DELETABLE\n            if (actionHeader.canComputeNode)\n                return NodeActions.ButtonState.LAUNCHABLE\n            return NodeActions.ButtonState.DISABLED\n        }\n\n        function getSubmitButtonState(node) {\n            if (actionHeader.canStopNode)\n                return NodeActions.ButtonState.STOPPABLE\n            if (!actionHeader.nodeIsLocked && node.globalStatus == \"SUCCESS\")\n                return NodeActions.ButtonState.DELETABLE\n            if (actionHeader.canSubmitNode)\n                return NodeActions.ButtonState.LAUNCHABLE\n            return NodeActions.ButtonState.DISABLED\n        }\n        \n        function isSubmittedExternally(node) {\n            return node.globalExecMode == \"EXTERN\" && [\"RUNNING\", \"SUBMITTED\"].includes(node.globalStatus)\n        }\n        \n        function isNodeRestartable(node) {\n            return actionHeader.computeButtonState == NodeActions.ButtonState.LAUNCHABLE && \n                [\"ERROR\", \"STOPPED\", \"KILLED\"].includes(node.globalStatus)\n        }\n\n        function isNodeRetriable(node) {\n            return node.globalExecMode == \"EXTERN\" && [\"ERROR\", \"STOPPED\", \"KILLED\"].includes(node.globalStatus)\n        }\n\n        function updateProperties(node) {\n            if (!node) return\n            // Update properties values\n            actionHeader.canComputeNode = uigraph.canComputeNode(node)\n            actionHeader.canSubmitNode = uigraph.canSubmitNode(node)\n            actionHeader.canStopNode = node.canBeStopped() || node.canBeCanceled()\n            actionHeader.nodeIsLocked = node.locked\n            actionHeader.nodeSubmitted = isSubmittedExternally(node)\n            // Update button states\n            actionHeader.computeButtonState = getComputeButtonState(node)\n            actionHeader.submitButtonState = getSubmitButtonState(node)\n            actionHeader.canRestartNode = isNodeRestartable(node)\n            actionHeader.canRetryNode = isNodeRetriable(node)\n        }\n\n        // Set initial state & position\n        onSelectedNodeDelegateChanged: {\n            if (actionHeader.selectedNode) {\n                actionHeader.updateProperties(actionHeader.selectedNode)\n                Qt.callLater(actionHeader.updatePosition)\n            }\n        }\n\n        // Listen to updates to status\n        Connections {\n            target: actionHeader.selectedNode\n            function onGlobalStatusChanged() {\n                actionHeader.updateProperties(target)\n            }\n            function onLockedChanged() { \n                actionHeader.nodeIsLocked = target.locked\n            }\n            ignoreUnknownSignals: true\n        }\n\n        // Listen to updates from nodes that are not selected\n        Connections {\n            target: root.uigraph\n            function onComputingChanged() { \n                actionHeader.updateProperties(actionHeader.selectedNode)\n            }\n            ignoreUnknownSignals: true\n        }\n\n        Row {\n            id: actionItemsRow\n            anchors.centerIn: parent\n            spacing: 2\n\n            // Compute button\n            MaterialToolButton {\n                id: computeButton\n                font.pointSize: 16\n                text: actionHeader.computeButtonIcon\n                padding: 6\n                ToolTip.text: actionHeader.computeButtonTooltip\n                ToolTip.visible: hovered\n                ToolTip.delay: 1000\n                visible: actionHeader.computeButtonState != NodeActions.ButtonState.DISABLED\n                enabled: visible && !actionHeader.nodeSubmitted // Launchable & Stoppable, local\n                // Icon color\n                textColor: checked ? palette.highlight : palette.text\n                // Background color\n                background: Rectangle {\n                    color: {\n                        if (!computeButton.enabled)\n                            return activePalette.button\n                        if (actionHeader.computeButtonState == NodeActions.ButtonState.STOPPABLE)\n                            return computeButton.hovered ? Colors.orange : Qt.darker(Colors.orange, 1.3)\n                        return computeButton.hovered ? activePalette.highlight : activePalette.button\n                    }\n                    opacity: computeButton.hovered ? 1 : root._opacity\n                    border.color: computeButton.hovered ? activePalette.highlight : Qt.darker(activePalette.window, 1.3)\n                    border.width: 1\n                    radius: 3\n                }\n                onClicked: {\n                    switch (actionHeader.computeButtonState) {\n                        case NodeActions.ButtonState.STOPPABLE:\n                            root.stopComputeRequest(actionHeader.selectedNode)\n                            break\n                        case NodeActions.ButtonState.LAUNCHABLE:\n                            root.computeRequest(actionHeader.selectedNode)\n                            break\n                        case NodeActions.ButtonState.DELETABLE:\n                            root.deleteDataRequest(actionHeader.selectedNode)\n                            root.computeRequest(actionHeader.selectedNode)\n                            break\n                        default:\n                            break\n                    }\n                }\n            }\n\n            // Clear node\n            MaterialToolButton {\n                id: deleteDataButton\n                font.pointSize: 16\n                text: MaterialIcons.delete_\n                padding: 6\n                ToolTip.text: \"Delete Data\"\n                ToolTip.visible: hovered\n                ToolTip.delay: 1000\n                visible: actionHeader.canRestartNode || actionHeader.computeButtonState == NodeActions.ButtonState.DELETABLE\n                enabled: visible\n                background: Rectangle {\n                    color: computeButton.hovered ? Colors.red : Qt.darker(Colors.red, 1.3)\n                    opacity: computeButton.hovered ? 1 : root._opacity\n                    border.color: computeButton.hovered ? activePalette.highlight : Qt.darker(activePalette.window, 1.3)\n                    border.width: 1\n                    radius: 3\n                }\n                onClicked: {\n                    root.deleteDataRequest(actionHeader.selectedNode)\n                }\n            }\n\n            // Submit button\n            MaterialToolButton {\n                id: submitButton\n                font.pointSize: 16\n                text: actionHeader.submitButtonIcon\n                padding: 6\n                ToolTip.text: actionHeader.submitButtonTooltip\n                ToolTip.visible: hovered\n                ToolTip.delay: 1000\n                visible: actionHeader.submitButtonState != NodeActions.ButtonState.DISABLED\n                enabled: visible && (actionHeader.nodeSubmitted || !actionHeader.nodeIsLocked)  // Launchable & Stoppable, external\n                // Icon color\n                textColor: checked ? palette.highlight : palette.text\n                // Background color\n                background: Rectangle {\n                    color: {\n                        if (!submitButton.enabled)\n                            return activePalette.button\n\n                        if (actionHeader.submitButtonState == NodeActions.ButtonState.STOPPABLE)\n                            return submitButton.hovered ? Colors.orange : Qt.darker(Colors.orange, 1.3)\n                        return submitButton.hovered ? activePalette.highlight : activePalette.button\n                    }\n                    opacity: submitButton.hovered ? 1 : root._opacity\n                    border.color: submitButton.hovered ? activePalette.highlight : Qt.darker(activePalette.window, 1.3)\n                    border.width: 1\n                    radius: 3\n                }\n                onClicked: {\n                    switch (actionHeader.submitButtonState) {\n                        case NodeActions.ButtonState.STOPPABLE:\n                            root.stopSubmitRequest(actionHeader.selectedNode)\n                            break\n                        case NodeActions.ButtonState.LAUNCHABLE:\n                            root.submitRequest(actionHeader.selectedNode)\n                            actionHeader.updateProperties(actionHeader.selectedNode)\n                            break\n                        case NodeActions.ButtonState.DELETABLE:\n                            root.deleteDataRequest(actionHeader.selectedNode)\n                            root.submitRequest(actionHeader.selectedNode)\n                            break\n                        default:\n                            break\n                    }\n                }\n            }\n\n            // Retry button (for farm submissions that have failed)\n            MaterialToolButton {\n                id: retryButton\n                font.pointSize: 16\n                text: MaterialIcons.cloud_sync\n                padding: 6\n                ToolTip.text: \"Retry Submission On Render Farm\"\n                ToolTip.visible: hovered\n                ToolTip.delay: 1000\n                visible: actionHeader.canRetryNode\n                enabled: visible\n\n                // Background color\n                background: Rectangle {\n                    color: {\n                        return retryButton.hovered ? activePalette.highlight : activePalette.button\n                    }\n                    opacity: retryButton.hovered ? 1 : root._opacity\n                    border.color: retryButton.hovered ? activePalette.highlight : Qt.darker(activePalette.window, 1.3)\n                    border.width: 1\n                    radius: 3\n                }\n\n                onClicked: {\n                    root.retrySubmitRequest(actionHeader.selectedNode)\n                }\n            }\n        }\n    }\n}"
  },
  {
    "path": "meshroom/ui/qml/Controls/Panel.qml",
    "content": "import QtQuick\nimport QtQuick.Controls\nimport QtQuick.Layouts\n\n/**\n * Panel is a container control with preconfigured header/footer.\n *\n * The header displays an optional icon and the title of the Panel,\n * and provides a placeholder (headerBar) at the top right corner, useful to create a contextual toolbar.\n *\n *\n * The footer is empty (and not visible) by default. It does not provided any layout.\n */\n\nPage {\n    id: root\n\n    property alias headerBar: headerLayout.data\n    property Component titleComponent: null  // Allow custom component for title\n    property alias footerContent: footerLayout.data\n    property alias icon: iconPlaceHolder.data\n    property alias loading: loadingIndicator.running\n    property alias loadingText: loadingLabel.text\n\n    clip: true\n\n    QtObject {\n        id: m\n        property int hPadding: 6\n        property int vPadding: 4\n        readonly property color paneBackgroundColor: Qt.darker(root.palette.window, 1.15)\n    }\n\n    padding: 1\n\n    header: Pane {\n        id: headerPane\n        topPadding: m.vPadding; bottomPadding: m.vPadding\n        leftPadding: m.hPadding; rightPadding: m.hPadding\n        background: Item {\n            Rectangle {\n                anchors.fill: parent\n                color: m.paneBackgroundColor\n            }\n            MouseArea {\n                anchors.fill: parent\n                onPressed: {\n                    headerLayout.forceActiveFocus()\n                }\n            }\n        }\n\n        RowLayout {\n            width: parent.width\n\n            // Icon\n            Item {\n                id: iconPlaceHolder\n                width: childrenRect.width\n                height: childrenRect.height\n                Layout.alignment: Qt.AlignVCenter\n                visible: icon !== \"\"\n            }\n\n            // Title\n            // Either we load the custom root.titleComponent or we just put the root.title\n            Loader {\n                id: titleLoader\n                sourceComponent: root.titleComponent !== null ? root.titleComponent : defaultTitleComponent\n                Layout.fillWidth: false\n            }\n            Component {\n                id: defaultTitleComponent\n                Label {\n                    text: root.title\n                    elide: Text.ElideRight\n                    topPadding: m.vPadding\n                    bottomPadding: m.vPadding\n                }\n            }\n\n            Item {\n                width: 10\n            }\n            // Feature loading status\n            BusyIndicator {\n                id: loadingIndicator\n                padding: 0\n                implicitWidth: 12\n                implicitHeight: 12\n                running: false\n            }\n            Label {\n                id: loadingLabel\n                text: \"\"\n                font.italic: true\n            }\n            Item {\n                Layout.fillWidth: true\n            }\n\n            // Header menu\n            Row { id: headerLayout }\n        }\n    }\n\n    footer: Pane {\n        id: footerPane\n        topPadding: m.vPadding; bottomPadding: m.vPadding\n        leftPadding: m.hPadding; rightPadding: m.hPadding\n        visible: footerLayout.children.length > 0\n        background: Item {\n            Rectangle {\n                anchors.fill: parent\n                color: m.paneBackgroundColor\n            }\n            MouseArea {\n                anchors.fill: parent\n                onPressed: {\n                    footerLayout.forceActiveFocus()\n                }\n            }\n        }\n\n        // Content place holder\n        RowLayout {\n            id: footerLayout\n            width: parent.width\n        }\n    }\n}\n"
  },
  {
    "path": "meshroom/ui/qml/Controls/SearchBar.qml",
    "content": "import QtQuick\nimport QtQuick.Controls\nimport QtQuick.Layouts\n\nimport MaterialIcons 2.2\n\n/**\n * Basic SearchBar component with an appropriate icon and a TextField.\n */\n\nFocusScope {\n    id: root\n    property alias textField: field\n    property alias text: field.text\n\n    // Enables hiding and showing of the text field on Search button click\n    property bool toggle: false\n    property bool isVisible: false\n\n    // Size properties\n    property int maxWidth: 150\n    property int minWidth: 30\n\n    // The default width is computed based on whether toggling is enabled and if the visibility is true\n    width: toggle && isVisible ? maxWidth : minWidth\n\n    // Keyboard interaction related signals\n    signal accepted()\n\n    implicitHeight: childrenRect.height\n    Keys.forwardTo: [field]\n\n    function forceActiveFocus() {\n        root.isVisible = true\n        field.forceActiveFocus()\n    }\n\n    function clear() {\n        field.clear()\n    }\n\n    RowLayout {\n        spacing: 0\n        width: parent.width\n\n        MaterialToolButton {\n            text: MaterialIcons.search\n\n            onClicked: {\n                root.isVisible = !root.isVisible\n                // Set Focus on the Text Field\n                field.focus = field.visible\n            }\n        }\n\n        TextField {\n            id: field\n            focus: true\n            Layout.fillWidth: true\n            selectByMouse: true\n\n            rightPadding: clear.width\n\n            // The text field is visible either when toggle is not activated or the visible property is set\n            visible: root.toggle ? root.isVisible : true\n\n            // Ensure the field has focus when the text is modified\n            onTextChanged: {\n                forceActiveFocus()\n            }\n\n            // Handle enter Key press and forward it to the parent\n            Keys.onPressed: (event)=> {\n                if ((event.key == Qt.Key_Return || event.key == Qt.Key_Enter)) {\n                    event.accepted = true\n                    root.accepted()\n                } else if (event.key == Qt.Key_Escape) {\n                    root.isVisible = false\n                    field.focus = false\n                }\n            }\n\n            MaterialToolButton {\n                id: clear\n\n                // Anchors\n                anchors.right: parent.right\n                anchors.rightMargin: 2  // Leave a tiny bit of space so that its highlight does not overlap with the boundary of the parent\n                anchors.verticalCenter: parent.verticalCenter\n\n                // Style\n                font.pointSize: 8\n                text: MaterialIcons.close\n                ToolTip.text: \"Clears text.\"\n\n                // States\n                visible: field.text\n\n                // Signals -> Slots\n                onClicked: {\n                    field.text = \"\"\n                    parent.focus = true\n                }\n            }\n        }\n    }\n}\n\n\n"
  },
  {
    "path": "meshroom/ui/qml/Controls/SelectionBox.qml",
    "content": "import QtQuick\n\n/*\nSimple selection box that can be used by a MouseArea.\n\nUsage:\n1. Create a MouseArea and a SelectionBox.\n2. Bind the SelectionBox to the MouseArea by setting the `mouseArea` property.\n3. Call startSelection() with coordinates when the selection starts.\n4. Call endSelection() when the selection ends.\n5. Listen to the selectionEnded signal to get the selection rectangle.\n*/\n\nItem {\n    id: root\n\n    property MouseArea mouseArea\n    property alias color: selectionBox.color\n    property alias border: selectionBox.border\n\n    readonly property bool active: mouseArea.drag.target == dragTarget\n\n    signal selectionEnded(rect selectionRect, int modifiers)\n\n    function startSelection(mouse) {\n        dragTarget.startPos.x = dragTarget.x = mouse.x;\n        dragTarget.startPos.y = dragTarget.y = mouse.y;\n        dragTarget.modifiers = mouse.modifiers;\n        mouseArea.drag.target = dragTarget;\n    }\n\n    function endSelection() {\n        if (!active) {\n            return;\n        }\n        mouseArea.drag.target = null;\n        const rect = Qt.rect(selectionBox.x, selectionBox.y, selectionBox.width, selectionBox.height)\n        selectionEnded(rect, dragTarget.modifiers);\n    }\n\n    visible: active\n\n    Rectangle {\n        id: selectionBox\n        color: \"#109b9b9b\"\n        border.width: 1\n        border.color: \"#b4b4b4\"\n\n        x: Math.min(dragTarget.startPos.x, dragTarget.x)\n        y: Math.min(dragTarget.startPos.y, dragTarget.y)\n        width: Math.abs(dragTarget.x - dragTarget.startPos.x)\n        height: Math.abs(dragTarget.y - dragTarget.startPos.y)\n    }\n\n    Item {\n        id: dragTarget\n        property point startPos\n        property var modifiers\n    }\n}\n"
  },
  {
    "path": "meshroom/ui/qml/Controls/SelectionLine.qml",
    "content": "import QtQuick\nimport QtQuick.Shapes\n\n/*\nSimple selection line that can be used by a MouseArea.\n\nUsage:\n1. Create a MouseArea and a selectionShape.\n2. Bind the selectionShape to the MouseArea by setting the `mouseArea` property.\n3. Call startSelection() with coordinates when the selection starts.\n4. Call endSelection() when the selection ends.\n5. Listen to the selectionEnded signal to get the segment (defined by 2 points).\n*/\n\nItem {\n    id: root\n\n    property MouseArea mouseArea\n\n    readonly property bool active: mouseArea.drag.target == dragTarget\n\n    signal selectionEnded(point selectionP1, point selectionP2, int modifiers)\n\n    function startSelection(mouse) {\n        dragTarget.startPos.x = dragTarget.x = mouse.x;\n        dragTarget.startPos.y = dragTarget.y = mouse.y;\n        dragTarget.modifiers = mouse.modifiers;\n        mouseArea.drag.target = dragTarget;\n    }\n\n    function endSelection() {\n        if (!active) {\n            return;\n        }\n        mouseArea.drag.target = null;\n        const p1 = Qt.point(selectionShape.x, selectionShape.y);\n        const p2 = Qt.point(selectionShape.x + selectionShape.width, selectionShape.y + selectionShape.height);\n        selectionEnded(p1, p2, dragTarget.modifiers);\n    }\n\n    visible: active\n\n    Item {\n        id: selectionShape\n        x: dragTarget.startPos.x\n        y: dragTarget.startPos.y\n        width: dragTarget.x - dragTarget.startPos.x\n        height: dragTarget.y - dragTarget.startPos.y\n\n        Shape {\n            id: dynamicLine;\n            width: selectionShape.width;\n            height: selectionShape.height;\n            anchors.fill: parent;\n\n            ShapePath {\n                strokeWidth: 2;\n                strokeStyle: ShapePath.DashLine;\n                strokeColor: \"#CC3E3E\";\n                dashPattern: [3, 2];\n\n                startX: 0;\n                startY: 0;\n\n                PathLine {\n                    x: selectionShape.width;\n                    y: selectionShape.height;\n                }\n            }\n        }\n    }\n\n    Item {\n        id: dragTarget\n        property point startPos\n        property var modifiers\n    }\n}\n"
  },
  {
    "path": "meshroom/ui/qml/Controls/StatusBar.qml",
    "content": "import QtQuick\nimport QtQuick.Controls\nimport QtQuick.Layouts\nimport MaterialIcons 2.2\n\nimport Utils 1.0\n\nRowLayout {\n    id: root\n\n    property color  defaultColor: Qt.darker(palette.text, 1.2)\n    property string defaultIcon : MaterialIcons.circle\n    property int    interval    : 5000\n    property bool   logMessage  : false\n\n    TextField {\n        id: statusBarField\n        Layout.fillHeight: true\n        readOnly: true\n        selectByMouse: true\n        text: statusBar.message\n        color: defaultColor\n        background: Item {}\n        visible: statusBar.message !== \"\"\n    }\n\n    // TODO : Idea for later : implement a ProgressBar here\n\n    MaterialToolButton {\n        id: statusBarButton\n        Layout.fillHeight: true\n        Layout.preferredWidth: 17\n        visible: true\n        font.pointSize: 8\n        text: defaultIcon\n        ToolTip.text: \"Open Messages UI\"\n        onClicked: {\n            var component = Qt.createComponent(\"StatusMessages.qml\")\n            var window    = component.createObject(root)\n            window.show()\n        }\n        Component.onCompleted: {\n            statusBarButton.contentItem.color = defaultColor\n        }\n    }\n\n    Timer {\n        id: statusBarTimer\n        interval: root.interval\n        running: false\n        repeat: false\n        onTriggered: {\n            // Erase message and reset button icon\n            statusBar.message = \"\"\n            statusBarField.color = defaultColor\n            statusBarButton.contentItem.color = defaultColor\n            statusBarButton.text = defaultIcon\n        }\n    }\n\n    QtObject {\n        id: statusBar\n        property string message: \"\"\n\n        function showMessage(msg, status=undefined, duration=root.interval) {\n            var textColor = defaultColor\n            var logLevel = \"info\"\n            switch (status) {\n                case \"ok\": {\n                    statusBarField.color = Colors.green\n                    statusBarButton.text = MaterialIcons.check_circle\n                    break\n                }\n                case \"warning\": {\n                    logLevel = \"warn\"\n                    statusBarField.color = Colors.orange\n                    statusBarButton.text = MaterialIcons.warning\n                    break\n                }\n                case \"error\": {\n                    logLevel = \"error\"\n                    statusBarField.color = Colors.red\n                    statusBarButton.text = MaterialIcons.error\n                    break\n                }\n                default: {\n                    statusBarButton.text = defaultIcon\n                }\n            }\n            if (logMessage === true) {\n                console.log(\"[Message][\" + logLevel.toUpperCase().padEnd(5) + \"] \" + msg)\n            }\n            statusBarButton.contentItem.color = statusBarField.color\n            statusBar.message = msg\n            statusBarTimer.interval = duration\n            statusBarTimer.restart()\n            MeshroomApp.forceUIUpdate()\n        }\n    }\n\n    function showMessage(msg, status=undefined, duration=root.interval) {\n        statusBar.showMessage(msg, status, duration)\n        // Add message to the message list\n        _messageController.storeMessage(msg, status)\n    }\n\n    Connections {\n        target: _messageController\n        function onMessage(message, color, duration) {\n            root.showMessage(message, color, duration)\n        }\n    }\n}\n"
  },
  {
    "path": "meshroom/ui/qml/Controls/StatusMessages.qml",
    "content": "import QtQuick\nimport QtQuick.Controls\nimport QtQuick.Layouts\n\nimport MaterialIcons 2.2\nimport Utils 1.0\n\nApplicationWindow {\n    id: root\n    title: \"Messages\"\n    width: 500\n    height: 400\n    minimumWidth: 350\n    minimumHeight: 250\n\n    SystemPalette { id: systemPalette }\n\n    function getColor(status) {\n        switch (status) {\n            case \"ok\": return Colors.green\n            case \"warning\": return Colors.orange\n            case \"error\": return Colors.red\n            default: return systemPalette.text\n        }\n    }\n\n    function getBackgroundColor(status) {\n        var color = getColor(status)\n        var alphaValue = status == \"info\" ? 0.05 : 0.1\n        return Qt.rgba(color.r, color.g, color.b, alphaValue)\n    }\n\n    function getBorderColor(status) {\n        var color = getColor(status)\n        var alphaValue = status == \"info\" ? 0.2 : 0.3\n        return Qt.rgba(color.r, color.g, color.b, alphaValue)\n    }\n\n    function getStatusIcon(status) {\n        switch (status) {\n            case \"ok\": return MaterialIcons.check_circle\n            case \"warning\": return MaterialIcons.warning\n            case \"error\": return MaterialIcons.error\n            default: return MaterialIcons.info\n        }\n    }\n\n    header: ToolBar {\n\n        background: Rectangle {\n            implicitWidth: root.width\n            implicitHeight: 50\n            color: Qt.darker(systemPalette.base, 1.2)\n        }\n        \n        RowLayout {\n            anchors.fill: parent\n            \n            Text {\n                Layout.fillWidth: true\n                text: \"Messages (\" + messageListView.count + \")\"\n                font.bold: true\n                color: Qt.darker(systemPalette.text, 1.2)\n            }\n            \n            MaterialToolButton {\n                ToolTip.text: \"Clear the message list\"\n                text: MaterialIcons.clear_all\n                font.pointSize: 16\n                palette.base: systemPalette.base\n                // Text color\n                Component.onCompleted: {\n                    contentItem.color = Qt.darker(systemPalette.text, 1.2)\n                }\n                onClicked: _messageController.clearMessages()\n            }\n\n            MaterialToolButton {\n                ToolTip.text: \"Copy the messages\"\n                text: MaterialIcons.content_copy\n                font.pointSize: 16\n                palette.base: systemPalette.base\n                // Text color\n                Component.onCompleted: {\n                    contentItem.color = Qt.darker(systemPalette.text, 1.2)\n                }\n                onClicked: {\n                    var msgDict = _messageController.getMessagesAsString()\n                    if (msgDict !== '') {\n                        Clipboard.clear()\n                        Clipboard.setText(msgDict)\n                    }\n                }\n            }\n        }\n    }\n\n    Rectangle {\n        anchors.fill: parent\n        color: systemPalette.base\n\n        ScrollView {\n            anchors.fill: parent\n            anchors.margins: 10\n\n            ListView {\n                id: messageListView\n                model: _messageController.messages\n                verticalLayoutDirection: ListView.TopToBottom\n                spacing: 5\n\n                delegate: Rectangle {\n                    width: messageListView.width\n                    height: messageLayout.implicitHeight + 16\n                    color: root.getBackgroundColor(modelData.status)\n                    border.color: root.getBorderColor(modelData.status)\n                    border.width: 1\n                    radius: 4\n\n                    RowLayout {\n                        id: messageLayout\n                        anchors.fill: parent\n                        anchors.margins: 8\n                        spacing: 12\n\n                        // Icon\n                        Text {\n                            text: root.getStatusIcon(modelData.status)\n                            font.pointSize: 14\n                            color: root.getColor(modelData.status)\n                            Layout.alignment: Qt.AlignVCenter\n                        }\n\n                        // Text\n                        RowLayout {\n                            Layout.fillWidth: true\n                            spacing: 8\n\n                            Text {\n                                text: modelData.date\n                                font.pointSize: 8\n                                color: Qt.darker(systemPalette.windowText, 1.5)\n                                Layout.alignment: Qt.AlignLeft\n                            }\n\n                            Text {\n                                text: modelData.text\n                                wrapMode: Text.WordWrap\n                                Layout.fillWidth: true\n                                color: systemPalette.windowText\n                                font.pointSize: 10\n                            }\n                        }\n                    }\n                }\n\n                // Empty state\n                Text {\n                    anchors.centerIn: parent\n                    text: \"No message to display\"\n                    color: Qt.darker(systemPalette.windowText, 1.5)\n                    font.pointSize: 12\n                    visible: messageListView.count === 0\n                }\n            }\n        }\n    }\n}"
  },
  {
    "path": "meshroom/ui/qml/Controls/TabPanel.qml",
    "content": "import QtQuick\nimport QtQuick.Controls\nimport QtQuick.Layouts\n\nPage {\n    id: root\n\n    property alias headerBar: headerLayout.data\n    property alias footerContent: footerLayout.data\n\n    property var tabs: []\n    property int currentTab: 0\n    onCurrentTabChanged: if (mainTabBar.currentIndex !== currentTab) mainTabBar.currentIndex = currentTab\n\n    clip: true\n\n    QtObject {\n        id: m\n        readonly property color paneBackgroundColor: Qt.darker(root.palette.window, 1.15)\n    }\n    padding: 0\n\n    header: Pane {\n        id: headerPane\n        padding: 0\n        background: Rectangle { color: m.paneBackgroundColor }\n\n        RowLayout {\n            width: parent.width\n            spacing: 0\n\n            TabBar {\n                id: mainTabBar\n                padding: 4\n                Layout.fillWidth: true\n                onCurrentIndexChanged: root.currentTab = currentIndex\n\n                Repeater {\n                    model: root.tabs\n\n                    TabButton {\n                        text: modelData\n                        y: mainTabBar.padding\n                        padding: 4\n                        width: text.length * font.pointSize\n                        background: Rectangle {\n                            color: index === mainTabBar.currentIndex ? root.palette.window : Qt.darker(root.palette.window, 1.30)\n                        }\n\n                        Rectangle {\n                            property bool commonBorder: false\n\n                            property int lBorderwidth: index === mainTabBar.currentIndex ? 2 : 1\n                            property int rBorderwidth: index === mainTabBar.currentIndex ? 2 : 1\n                            property int tBorderwidth: index === mainTabBar.currentIndex ? 2 : 1\n                            property int bBorderwidth: 0\n\n                            property int commonBorderWidth: 1\n\n                            z: -1\n\n                            color: Qt.darker(root.palette.window, 1.50)\n\n                            anchors {\n                                left: parent.left\n                                right: parent.right\n                                top: parent.top\n                                bottom: parent.bottom\n\n                                topMargin: commonBorder ? -commonBorderWidth : -tBorderwidth\n                                bottomMargin: commonBorder ? -commonBorderWidth : -bBorderwidth\n                                leftMargin: commonBorder ? -commonBorderWidth : -lBorderwidth\n                                rightMargin: commonBorder ? -commonBorderWidth : -rBorderwidth\n                            }\n                        }\n                    }\n                }\n            }\n\n            Row { id: headerLayout }\n        }\n    }\n\n    footer: Pane {\n        id: footerPane\n        visible: footerLayout.children.length > 0\n        background: Rectangle { color: m.paneBackgroundColor }\n\n        RowLayout {\n            id: footerLayout\n            width: parent.width\n        }\n    }\n}\n"
  },
  {
    "path": "meshroom/ui/qml/Controls/TextFileViewer.qml",
    "content": "import QtQuick\nimport QtQuick.Controls\nimport QtQuick.Layouts\n\nimport MaterialIcons 2.2\nimport Utils 1.0\nimport DataObjects 1.0\n\n/**\n * Text file viewer with auto-reload feature.\n * Uses a ListView with one delegate by line instead of a TextArea for performance reasons.\n */\n\nItem {\n    id: root\n\n    /// Source text file to load\n    property url source\n    /// Whether to periodically reload the source file\n    property bool autoReload: false\n    /// Interval (in ms) at which source file should be reloaded if autoReload is enabled\n    property int autoReloadInterval: 2000\n    /// Whether the source is currently being loaded\n    property bool loading: false\n    /// Whether a large file warning is being displayed (file > 500 MB)\n    property bool largeFileWarning: false\n    /// File size in MB when a large file warning is displayed\n    property real largeFileSizeMB: 0\n    /// Human-readable file size string for the large file warning\n    readonly property string largeFileSizeStr: Format.GB2SizeStr(largeFileSizeMB / 1024)\n    /// Whether the user confirmed loading the current large source file\n    property bool confirmLargeLoad: false\n\n    onSourceChanged: {\n        confirmLargeLoad = false\n        loadSource()\n    }\n    onAutoReloadChanged: loadSource()\n    onVisibleChanged: if (visible) loadSource()\n\n    RowLayout {\n        anchors.fill: parent\n        spacing: 0\n\n        // Toolbar\n        Pane {\n            Layout.alignment: Qt.AlignTop\n            Layout.fillHeight: true\n            padding: 0\n            background: Rectangle { color: Qt.darker(Colors.sysPalette.window, 1.2) }\n            Column {\n                height: parent.height\n                spacing: 1\n                MaterialToolButton {\n                    text: MaterialIcons.refresh\n                    ToolTip.text: \"Reload\"\n                    onClicked: loadSource()\n                }\n                MaterialToolButton {\n                    text: MaterialIcons.vertical_align_top\n                    ToolTip.text: \"Scroll to Top\"\n                    onClicked: textView.positionViewAtBeginning()\n                }\n                MaterialToolButton {\n                    id: autoscroll\n                    text: MaterialIcons.vertical_align_bottom\n                    ToolTip.text: \"Scroll to Bottom\"\n                    onClicked: textView.positionViewAtEnd()\n                    checkable: false\n                    checked: textView.atYEnd\n                }\n                MaterialToolButton {\n                    text: MaterialIcons.assignment\n                    ToolTip.text: \"Copy\"\n                    onClicked: copySubMenu.open()\n                    Menu {\n                        id: copySubMenu\n                        x: parent.width\n\n                        MenuItem {\n                            text: \"Copy Visible Text\"\n                            onTriggered: {\n                                var t = \"\"\n                                for (var i = textView.firstVisibleIndex(); i < textView.lastVisibleIndex(); ++i)\n                                    t += textView.model.get(i).line + \"\\n\"\n                                Clipboard.setText(t)\n                            }\n                        }\n                        MenuItem {\n                            text: \"Copy All\"\n                            onTriggered: {\n                                Clipboard.setText(textView.text)\n                            }\n                         }\n                    }\n                }\n                MaterialToolButton {\n                    text: MaterialIcons.open_in_new\n                    ToolTip.text: \"Open Externally\"\n                    enabled: root.source !== \"\"\n                    onClicked: Qt.openUrlExternally(root.source)\n                }\n            }\n        }\n\n        MouseArea {\n            Layout.fillWidth: true\n            Layout.fillHeight: true\n            Layout.margins: 4\n\n            ListView {\n                id: textView\n\n                property string text\n\n                LogLinesModel {\n                    id: logLinesModel\n                }\n\n                onTextChanged: {\n                    logLinesModel.setText(text);\n                }\n\n                model: logLinesModel\n                visible: text != \"\"\n\n                anchors.fill: parent\n                clip: true\n                focus: true\n\n                // Custom key navigation handling\n                keyNavigationEnabled: false\n                highlightFollowsCurrentItem: true\n                highlightMoveDuration: 0\n                Keys.onPressed: function(event) {\n                    switch (event.key) {\n                        case Qt.Key_Home:\n                            textView.positionViewAtBeginning()\n                            break\n                        case Qt.Key_End:\n                            textView.positionViewAtEnd()\n                            break\n                        case Qt.Key_Up:\n                            currentIndex = firstVisibleIndex()\n                            decrementCurrentIndex()\n                            break;\n                        case Qt.Key_Down:\n                            currentIndex = lastVisibleIndex()\n                            incrementCurrentIndex()\n                            break;\n                        case Qt.Key_PageUp:\n                            textView.positionViewAtIndex(firstVisibleIndex(), ListView.End)\n                            break\n                        case Qt.Key_PageDown:\n                            textView.positionViewAtIndex(lastVisibleIndex(), ListView.Beginning)\n                            break\n                    }\n                }\n\n                function setText(value) {\n                    // Store current first index\n                    var topIndex = firstVisibleIndex()\n                    // Store whether autoscroll to bottom is active\n                    var scrollToBottom = atYEnd && autoscroll.checked\n                    // Replace text\n                    text = value\n\n                    // Restore content position by either:\n                    //  - autoscrolling to bottom\n                    if (scrollToBottom)\n                        positionViewAtEnd()\n                    //  - setting first visible index back (when possible)\n                    else if (topIndex !== firstVisibleIndex())\n                        positionViewAtIndex(Math.min(topIndex, count - 1), ListView.Beginning)\n                }\n\n                function firstVisibleIndex() {\n                    return indexAt(contentX, contentY)\n                }\n\n                function lastVisibleIndex() {\n                    return indexAt(contentX, contentY + height - 2)\n                }\n\n                ScrollBar.vertical: MScrollBar { id: vScrollBar }\n\n                ScrollBar.horizontal: MScrollBar {}\n\n                // TextMetrics for line numbers column\n                TextMetrics {\n                    id: lineMetrics\n                    font.family: \"Monospace, Consolas, Monaco\"\n                    text: textView.count * 10\n                }\n\n                // TextMetrics for textual progress bar\n                TextMetrics {\n                    id: progressMetrics\n                    // Total number of character in textual progress bar\n                    property int count: 51\n                    property string character: '*'\n                    text: character.repeat(count)\n                }\n\n                delegate: RowLayout {\n                    width: textView.width\n                    spacing: 6\n\n                    property var logLine: {\n                        var entry = textView.model.get(index)\n                        if (entry)\n                        {\n                            return entry\n                        }\n                        \n                        return { \"line\": \"\", \"duration\": -1, \"time\": \"00:00:00\", \"level\": LogLevelEnum.INFO }\n                    }\n\n                    Item {\n                        Layout.minimumWidth: childrenRect.width\n                        Layout.fillHeight: true\n                        RowLayout {\n                            height: parent.height\n                            // Colored marker to quickly indicate duration\n                            Rectangle {\n                                width: 4\n                                Layout.fillHeight: true\n                                color: Colors.durationColor(logLine.duration)\n                            }\n                            // Line number\n                            Label {\n                                text: index + 1\n                                Layout.minimumWidth: lineMetrics.width\n                                rightPadding: 6\n                                Layout.fillHeight: true\n                                horizontalAlignment: Text.AlignRight\n                                color: \"#CCCCCC\"\n                            }\n                        }\n                        // Display a tooltip with the duration when hovered\n                        MouseArea {\n                            id: mouseArea\n                            hoverEnabled: true\n                            anchors.fill: parent\n                        }\n                        enabled: logLine.duration > 0\n                        ToolTip.text: \"Elapsed time: \" + Format.sec2timeStr(logLine.duration) + \"\\nTime: \" + (logLine.duration >= 0 ? logLine.time : \"Unknown\")\n                        ToolTip.visible: mouseArea.containsMouse && logLine.duration >= 0\n                    }\n\n                    Loader {\n                        id: delegateLoader\n                        Layout.fillWidth: true\n                        // Default line delegate\n                        sourceComponent: line_component\n\n                        // Line delegate selector based on content\n                        StateGroup {\n                            states: [\n                                State {\n                                    name: \"progressBar\"\n                                    // Detect textual progressbar (non-empty line with only progressbar character)\n                                    when: logLine.line.trim().length\n                                          && logLine.line.split(progressMetrics.character).length - 1 === logLine.line.trim().length\n                                    PropertyChanges {\n                                        target: delegateLoader\n                                        sourceComponent: progressBar_component\n                                    }\n                                }\n                            ]\n                        }\n\n                        // ProgressBar delegate\n                        Component {\n                            id: progressBar_component\n                            Item {\n                                Layout.fillWidth: true\n                                implicitHeight: progressMetrics.height\n                                ProgressBar {\n                                    width: progressMetrics.width\n                                    height: parent.height - 2\n                                    anchors.verticalCenter: parent.verticalCenter\n                                    from: 0\n                                    to: progressMetrics.count\n                                    value: logLine.line.length\n                                }\n                            }\n                        }\n\n                        // Default line delegate\n                        Component {\n                            id: line_component\n                            TextInput {\n                                wrapMode: Text.WrapAnywhere\n                                text: logLine.line\n                                font.family: \"Monospace, Consolas, Monaco\"\n                                padding: 0\n                                selectByMouse: true\n                                readOnly: true\n                                selectionColor: Colors.sysPalette.highlight\n                                persistentSelection: false\n                                Keys.forwardTo: [textView]\n\n                                color: {\n                                    // Color line according to log level\n                                    switch (logLine.level)\n                                    {\n                                    case LogLevelEnum.TRACE:\n                                        return Qt.darker(palette.text, 2)\n                                    case LogLevelEnum.DEBUG:\n                                        return Qt.darker(palette.text, 1.5)\n                                    case LogLevelEnum.WARNING:\n                                        return Colors.orange\n                                    case LogLevelEnum.ERROR:\n                                        return Colors.red\n                                    case LogLevelEnum.FATAL:\n                                    case LogLevelEnum.CRITICAL:\n                                        return Colors.firebrick\n                                    default:\n                                        return palette.text\n                                    }\n                                }\n                            }\n                        }\n                    }\n                }\n            }\n\n            RowLayout {\n                anchors.fill: parent\n                anchors.rightMargin: vScrollBar.width\n                z: -1\n\n                Item {\n                    Layout.preferredWidth: lineMetrics.width\n                    Layout.fillHeight: true\n                }\n\n                // IBeamCursor shape overlay\n                MouseArea {\n                    Layout.fillWidth: true\n                    Layout.fillHeight: true\n                    cursorShape: Qt.IBeamCursor\n                }\n            }\n\n            // File loading indicator\n            BusyIndicator {\n                Component.onCompleted: running = Qt.binding(function() { return root.loading })\n                padding: 0\n                anchors.right: parent.right\n                anchors.bottom: parent.bottom\n                implicitWidth: 16\n                implicitHeight: 16\n            }\n\n            // Large file warning overlay\n            ColumnLayout {\n                visible: root.largeFileWarning\n                anchors.centerIn: parent\n                spacing: 8\n\n                Label {\n                    Layout.alignment: Qt.AlignHCenter\n                    font.family: MaterialIcons.fontFamily\n                    font.pointSize: 24\n                    text: MaterialIcons.warning\n                    color: Colors.orange\n                }\n                Label {\n                    Layout.alignment: Qt.AlignHCenter\n                    font.bold: true\n                    text: \"File size exceeds 500 MB\"\n                }\n                Label {\n                    Layout.alignment: Qt.AlignHCenter\n                    text: \"File size: \" + root.largeFileSizeStr\n                }\n                Label {\n                    Layout.alignment: Qt.AlignHCenter\n                    text: \"Loading this file may take a while and freeze the interface.\"\n                }\n                Button {\n                    Layout.alignment: Qt.AlignHCenter\n                    text: \"Load File (\" + root.largeFileSizeStr + \")\"\n                    onClicked: {\n                        root.confirmLargeLoad = true\n                        root.largeFileWarning = false\n                        root._performLoad()\n                    }\n                }\n            }\n        }\n    }\n\n    // Auto-reload current file timer\n    Timer {\n        id: reloadTimer\n        running: root.autoReload\n        interval: root.autoReloadInterval\n        repeat: false // timer is restarted in request's callback (see loadSource)\n        onTriggered: loadSource()\n    }\n\n\n    // Load current source file and update ListView's model\n    function loadSource() {\n        if (!visible)\n            return\n\n        // Check file size before loading (unless user already confirmed for this source)\n        if (!confirmLargeLoad) {\n            var fSizeMB = Filepath.fileSizeMB(root.source)\n            if (fSizeMB > 500) {\n                textView.setText(\"\")\n                largeFileSizeMB = fSizeMB\n                largeFileWarning = true\n                return\n            }\n        }\n\n        largeFileWarning = false\n        _performLoad()\n    }\n\n    // Internal function that performs the actual XHR file load, bypassing the size check\n    function _performLoad() {\n        loading = true\n        var xhr = new XMLHttpRequest\n\n        xhr.open(\"GET\", root.source)\n        xhr.onreadystatechange = function() {\n            // - cannot rely on 'Last-Modified' header response to verify\n            //   that file has changed on disk (not always up-to-date)\n            // - instead, let QML engine evaluate whether 'text' property value has changed\n            if (xhr.readyState === XMLHttpRequest.DONE) {\n                textView.setText(xhr.status === 200 ? xhr.responseText : \"\")\n                loading = false\n                // Re-trigger reload source file\n                if (autoReload)\n                    reloadTimer.restart()\n            }\n        }\n        xhr.send()\n    }\n}\n"
  },
  {
    "path": "meshroom/ui/qml/Controls/qmldir",
    "content": "module Controls\n\nColorChart 1.0 ColorChart.qml\nColorSelector 1.0 ColorSelector.qml\nExpandableGroup 1.0 ExpandableGroup.qml\nFloatingPane 1.0 FloatingPane.qml\nGroup 1.0 Group.qml\nKeyValue 1.0 KeyValue.qml\nMessageDialog 1.0 MessageDialog.qml\nPanel 1.0 Panel.qml\nSearchBar 1.0 SearchBar.qml\nTabPanel 1.0 TabPanel.qml\nTextFileViewer 1.0 TextFileViewer.qml\nExifOrientedViewer 1.0 ExifOrientedViewer.qml\nFilterComboBox 1.0 FilterComboBox.qml\nIntSelector 1.0 IntSelector.qml\nMScrollBar 1.0 MScrollBar.qml\nMSplitView 1.0 MSplitView.qml\nDirectionalLightPane 1.0 DirectionalLightPane.qml\nSelectionBox 1.0 SelectionBox.qml\nSelectionLine 1.0 SelectionLine.qml\nDelegateSelectionBox 1.0 DelegateSelectionBox.qml\nDelegateSelectionLine 1.0 DelegateSelectionLine.qml\nStatusBar 1.0 StatusBar.qml\nNodeActions 1.0 NodeActions.qml\n"
  },
  {
    "path": "meshroom/ui/qml/DialogsFactory.qml",
    "content": "import QtQuick\nimport Controls 1.0\n\n/**\n * DialogsFactory is utility object to instantiate generic purpose Dialogs.\n */\n\nQtObject {\n\n    readonly property string defaultErrorText: \"An unexpected error has occurred\"\n\n    property Component infoDialog: Component {\n        MessageDialog {\n            title: \"Info\"\n            preset: \"Info\"\n            visible: true\n        }\n    }\n    property Component warningDialog: Component {\n        MessageDialog {\n            title: \"Warning\"\n            preset: \"Warning\"\n            visible: true\n        }\n    }\n    property Component errorDialog: Component {\n        id: errorDialog\n        MessageDialog {\n            title: \"Error\"\n            preset: \"Error\"\n            text: defaultErrorText\n            visible: true\n        }\n    }\n\n    function info(window) {\n        return infoDialog.createObject(window)\n    }\n\n    function warning(window) {\n        return warningDialog.createObject(window)\n    }\n\n    function error(window) {\n        return errorDialog.createObject(window)\n    }\n}\n"
  },
  {
    "path": "meshroom/ui/qml/GraphEditor/AttributeControls/Choice.qml",
    "content": "import QtQuick\nimport QtQuick.Controls\nimport QtQuick.Layouts\n\nimport MaterialIcons\nimport Controls\n\n/**\n * A combobox-type control with a single current `value` and a list of possible `values`.\n * Provides filtering capabilities and support for custom values (i.e: `value` not in `values`).\n */\nRowLayout {\n    id: root\n\n    required property var value\n    required property var values\n\n    signal editingFinished(var value)\n\n    FilterComboBox {\n        id: comboBox\n\n        Layout.fillWidth: true\n        sourceModel: root.values\n        inputValue: root.value\n        onEditingFinished: value => root.editingFinished(value)\n    }\n\n    MaterialLabel {\n        visible: !comboBox.validValue\n        text: MaterialIcons.warning\n        ToolTip.text: \"Custom value detected\"\n    }\n}\n"
  },
  {
    "path": "meshroom/ui/qml/GraphEditor/AttributeControls/ChoiceMulti.qml",
    "content": "import QtQuick\nimport QtQuick.Controls\nimport Controls\n\n/**\n * A multi-checkboxes control with a current `value` (list of 0-N elements) and a list of possible `values`.\n * Provides support for custom values (`value` elements not in `values`).\n */\nFlow {\n    id: root\n\n    required property var value\n    required property var values\n    property color customValueColor: \"orange\"\n\n    signal toggled(var value, var checked)\n\n    // Predefined possible values.\n    Repeater {\n        model: root.values\n        delegate: CheckBox {\n            text: modelData\n            checked: root.value.includes(modelData)\n            onToggled: root.toggled(modelData, checked)\n        }\n    }\n\n    // Custom elements outside the predefined possible values.\n    Repeater {\n        model: root.value.filter(v => !root.values.includes(v))\n        delegate: CheckBox {\n            text: modelData\n            palette.text: root.customValueColor\n            font.italic: true\n            checked: true\n            ToolTip.text: \"Custom value\"\n            ToolTip.visible: hovered\n            onToggled: root.toggled(modelData, checked)\n        }\n    }\n}\n"
  },
  {
    "path": "meshroom/ui/qml/GraphEditor/AttributeEditor.qml",
    "content": "import QtQuick\nimport QtQuick.Controls\n\nimport Controls 1.0\n\n/**\n * A component to display and edit the attributes of a Node.\n */\n\nListView {\n    id: root\n    property bool readOnly: false\n    property int labelWidth: 180\n    property bool objectsHideable: true\n    property string filterText: \"\"\n\n    signal upgradeRequest()\n    signal attributeDoubleClicked(var mouse, var attribute)\n    signal inAttributeClicked(var srcItem, var mouse, var inAttributes)\n    signal outAttributeClicked(var srcItem, var mouse, var outAttributes)\n    signal showInViewer(var attribute)\n\n    implicitHeight: contentHeight\n\n    spacing: 2\n    clip: true\n    ScrollBar.vertical: MScrollBar { id: scrollBar }\n\n    delegate: Loader {\n        active: !object.hasDisplayableShape && (object.enabled || object.hasAnyOutputLinks) && (\n            !objectsHideable\n            || ((!object.desc.advanced || GraphEditorSettings.showAdvancedAttributes)\n            && (object.isDefault && GraphEditorSettings.showDefaultAttributes || !object.isDefault && GraphEditorSettings.showModifiedAttributes)\n            && (object.isOutput && GraphEditorSettings.showOutputAttributes || !object.isOutput && GraphEditorSettings.showInputAttributes)\n            && (object.hasAnyInputLinks && GraphEditorSettings.showLinkAttributes || !object.isLink && GraphEditorSettings.showNotLinkAttributes))\n            ) && object.matchText(filterText)\n        visible: active\n\n        sourceComponent: AttributeItemDelegate {\n            width: root.width - scrollBar.width\n            readOnly: root.readOnly\n            labelWidth: root.labelWidth\n            filterText: root.filterText\n            objectsHideable: root.objectsHideable\n            attribute: object\n\n            onDoubleClicked: function(mouse, attr) {\n                root.attributeDoubleClicked(mouse, attr)\n            }\n            onInAttributeClicked: function(srcItem, mouse, inAttributes) {\n                root.inAttributeClicked(srcItem, mouse, inAttributes)\n            }\n            onOutAttributeClicked: function(srcItem, mouse, outAttributes) {\n                root.outAttributeClicked(srcItem, mouse, outAttributes)\n            }\n\n            onShowInViewer: function(attr) {\n                root.showInViewer(attr)\n            }\n        }\n\n        onActiveChanged: height = active ? item.implicitHeight : -spacing\n\n        Connections {\n            target: item\n            function onImplicitHeightChanged() {\n                // Handles cases where an attribute is created and its height is then updated as it is filled\n                height = item.implicitHeight\n            }\n        }\n    }\n\n    // Helper MouseArea to lose edit/activeFocus when clicking on the background\n    MouseArea {\n        anchors.fill: parent\n        onClicked: forceActiveFocus()\n        z: -1\n    }\n}\n"
  },
  {
    "path": "meshroom/ui/qml/GraphEditor/AttributeItemDelegate.qml",
    "content": "import QtQuick\nimport QtQuick.Layouts\nimport QtQuick.Controls\nimport QtQuick.Dialogs\n\nimport MaterialIcons 2.2\nimport Utils 1.0\nimport Controls 1.0\nimport \"AttributeControls\" as AttributeControls\n\n/**\n * Instantiate a control to visualize and edit an Attribute based on its type.\n */\n\nRowLayout {\n    id: root\n\n    property variant attribute: null\n    property bool readOnly: false  // Whether the attribute's value can be modified\n    property bool objectsHideable: true\n    property string filterText: \"\"\n\n    property alias label: parameterLabel  // Accessor to the internal Label (attribute's name)\n    property int labelWidth               // Shortcut to set the fixed size of the Label\n\n    readonly property bool editable: !attribute.isOutput && !attribute.isLink &&   \n                                     !readOnly && !(attribute.keyable && _currentScene.selectedViewId === \"-1\")\n\n    signal doubleClicked(var mouse, var attr)\n    signal inAttributeClicked(var srcItem, var mouse, var inAttributes)\n    signal outAttributeClicked(var srcItem, var mouse, var outAttributes)\n    signal showInViewer(var attr)\n\n    spacing: 2\n\n    function updateAttributeLabel() {\n        background.color = attribute.isValid ?  Qt.darker(palette.window, 1.1) : Qt.darker(Colors.red, 1.5)\n\n        if (attribute.desc) {\n            var tooltip = \"\"\n            if (!attribute.isValid && attribute.desc.errorMessage !== \"\")\n                tooltip += \"<i><b>Error: </b>\" + Format.plainToHtml(attribute.desc.errorMessage) + \"</i><br><br>\"\n            tooltip += \"<b> \" + attribute.desc.name + \":</b> \" + attribute.type + \"<br>\" + Format.plainToHtml(attribute.desc.description)\n\n            parameterTooltip.text = tooltip\n        }\n    }\n\n    Pane {\n        background: Rectangle {\n            id: background\n            color: object != undefined && object.isValid ? Qt.darker(parent.palette.window, 1.1) : Qt.darker(Colors.red, 1.5)\n        }\n        padding: 0\n        Layout.preferredWidth: labelWidth || implicitWidth\n        Layout.fillHeight: true\n\n        RowLayout {\n            spacing: 0\n            width: parent.width\n            height: parent.height\n\n            // In connection\n            MaterialToolButton {\n                id: navButtonIn\n\n                property bool shouldBeVisible: (object != undefined && object.hasAnyInputLinks)\n\n                text: MaterialIcons.login\n                enabled: shouldBeVisible\n                font.pointSize: 8\n                Layout.fillHeight: true\n                visible: shouldBeVisible\n\n                MouseArea {\n                    anchors.fill: parent\n                    acceptedButtons: Qt.LeftButton | Qt.MiddleButton | Qt.RightButton\n\n                    onClicked: function(mouse) {\n                        root.inAttributeClicked(navButtonIn, mouse, object.allInputLinks)\n                    }\n                }\n\n            }\n\n            Label {\n                id: parameterLabel\n\n                Layout.fillHeight: true\n                Layout.fillWidth: true\n                horizontalAlignment: attribute.isOutput ? Qt.AlignRight : Qt.AlignLeft\n                verticalAlignment: Text.AlignVCenter\n                elide: Label.ElideRight\n                padding: 5\n                wrapMode: Label.WrapAtWordBoundaryOrAnywhere\n\n                text: object.label\n\n                color: {\n                    if (object != undefined && (object.hasAnyOutputLinks || object.isLink) && !object.enabled)\n                        return Colors.lightgrey\n                    else\n                        return palette.text\n                }\n\n                // Tooltip hint with attribute's description\n                ToolTip {\n                    id: parameterTooltip\n\n                    // Position in y at mouse position\n                    y: parameterMA.mouseY + 10\n\n                    text: {\n                        var tooltip = \"\"\n                        if (!object.isValid && object.desc.errorMessage !== \"\")\n                            tooltip += \"<i><b>Error: </b>\" + Format.plainToHtml(object.desc.errorMessage) + \"</i><br><br>\"\n                        tooltip += \"<b>\" + object.desc.name + \":</b> \" + attribute.type + \"<br>\" + Format.plainToHtml(object.desc.description)\n                        return tooltip\n                    }\n                    visible: parameterMA.containsMouse\n                    delay: 800\n                }\n\n                // Make label bold if attribute's value is not the default one\n                font.bold: !object.isOutput && !object.isDefault\n\n                // Make label italic if attribute is a link\n                font.italic: object.isLink\n\n                MouseArea {\n                    id: parameterMA\n                    anchors.fill: parent\n                    hoverEnabled: true\n                    acceptedButtons: Qt.AllButtons\n                    onDoubleClicked: function(mouse) { root.doubleClicked(mouse, root.attribute) }\n\n                    property Component menuComp: Menu {\n                        id: paramMenu\n\n                        property bool isFileAttribute: attribute.type === \"File\"\n                        property bool isFilepath: isFileAttribute && Filepath.isFile(attribute.evalValue)\n\n                        MenuItem {\n                            text: \"Reset To Default Value\"\n                            enabled: root.editable && !attribute.isDefault\n                            onTriggered: {\n                                _currentScene.resetAttribute(attribute)\n                                updateAttributeLabel()\n                            }\n                        }\n                        MenuItem {\n                            text: \"Copy\"\n                            enabled: !attribute.keyable && attribute.value != \"\"\n                            onTriggered: {\n                                Clipboard.clear()\n                                Clipboard.setText(attribute.value)\n                            }\n                        }\n                        MenuItem {\n                            text: \"Paste\"\n                            enabled: Clipboard.getText() != \"\" && !attribute.keyable && root.editable\n                            onTriggered: {\n                                _currentScene.setAttribute(attribute, Clipboard.getText())\n                            }\n                        }\n\n                        MenuSeparator {\n                            visible: paramMenu.isFileAttribute\n                            height: visible ? implicitHeight : 0\n                        }\n\n                        MenuItem {\n                            visible: paramMenu.isFileAttribute\n                            height: visible ? implicitHeight : 0\n                            text: paramMenu.isFilepath ? \"Open Containing Folder\" : \"Open Folder\"\n                            onClicked: paramMenu.isFilepath ? Qt.openUrlExternally(Filepath.dirname(attribute.evalValue)) :\n                                                              Qt.openUrlExternally(Filepath.stringToUrl(attribute.evalValue))\n                        }\n\n                        MenuItem {\n                            visible: paramMenu.isFilepath\n                            height: visible ? implicitHeight : 0\n                            text: \"Open File\"\n                            onClicked: Qt.openUrlExternally(Filepath.stringToUrl(attribute.evalValue))\n                        }\n\n                        MenuItem { \n                            visible: attribute.isOutput && (attribute.is2dDisplayable || attribute.is3dDisplayable || attribute.isTextDisplayable)\n                            height: visible ? implicitHeight : 0\n                            text: {\n                                if (attribute.is2dDisplayable)\n                                    return \"Show in 2D Viewer\"\n                                if (attribute.isTextDisplayable)\n                                    return \"Show in Text Viewer\"\n                                return \"Show in 3D Viewer\"\n                            }\n                            onClicked: root.showInViewer(attribute)\n                        }\n\n                    }\n\n                    onClicked: function(mouse) {\n                        forceActiveFocus()\n                        if (mouse.button == Qt.RightButton) {\n                            var menu = menuComp.createObject(parameterLabel)\n                            menu.parent = parameterLabel\n                            menu.popup()\n                        }\n                    }\n                }\n            }\n\n            MaterialLabel {\n                property bool isDisplayable: attribute.isOutput && (attribute.is2dDisplayable || attribute.is3dDisplayable || attribute.isTextDisplayable)\n                property bool isDisplayed: attribute === _currentScene.displayedAttr2D || _currentScene.displayedAttrs3D.count && _currentScene.displayedAttrs3D.contains(attribute)\n                text: isDisplayed ? MaterialIcons.visibility : MaterialIcons.visibility_off\n                enabled: isDisplayed\n                visible: isDisplayable\n                ToolTip.text: {\n                    if (attribute.is2dDisplayable)\n                        return \"This attribute is displayable in the 2D viewer.\"\n                    if (attribute.isTextDisplayable)\n                        return \"This attribute is displayable in the Text viewer.\"\n                    return \"This attribute is displayable in the 3D viewer.\"\n                }\n\n                padding: 4\n                font.pointSize: 8\n            }\n\n            MaterialToolButton {\n                id: navButtonOut\n\n                property bool shouldBeVisible: (attribute != undefined && attribute.hasAnyOutputLinks)\n\n                text: MaterialIcons.logout\n                font.pointSize: 8\n                enabled: shouldBeVisible\n                Layout.fillHeight: true\n                visible: shouldBeVisible\n\n                MouseArea {\n                    anchors.fill: parent\n                    acceptedButtons: Qt.LeftButton | Qt.MiddleButton | Qt.RightButton\n\n                    onClicked: function(mouse) {\n                        root.outAttributeClicked(navButtonOut, mouse, attribute.allOutputLinks)\n                    }\n                }\n\n\n            }\n\n            MaterialLabel {\n                visible: attribute.desc.advanced\n                text: MaterialIcons.build\n                color: palette.mid\n                font.pointSize: 8\n                padding: 4\n            }\n        }\n    }\n\n    function setTextFieldAttribute(value) {\n        // editingFinished called even when TextField is readonly\n        if (!editable)\n            return\n        switch (attribute.type) {\n            case \"IntParam\":\n            case \"FloatParam\":\n                // We do not set a number because we want to keep the invalid expression\n                if(attribute.keyable)\n                    _currentScene.addAttributeKeyValue(root.attribute, _currentScene.selectedViewId, Number(value))\n                else\n                    _currentScene.setAttribute(root.attribute, Number(value))\n                updateAttributeLabel()\n                break\n            case \"File\":\n                _currentScene.setAttribute(root.attribute, value)\n                break\n            default:\n                _currentScene.setAttribute(root.attribute, value.trim())\n                updateAttributeLabel()\n                break\n        }\n    }\n\n    Loader {\n        id: attributeLoader\n        Layout.fillWidth: true\n\n        sourceComponent: {\n            // PushButtonParam always has value == undefined, so it needs to be excluded from this check\n            if (attribute.type != \"PushButtonParam\" && !attribute.keyable && attribute.value === undefined) {\n                return notComputedComponent\n            }\n            switch (attribute.type) {\n                case \"PushButtonParam\":\n                    return pushButtonComponent\n                case \"ChoiceParam\":\n                    return attribute.desc.exclusive ? choiceComponent : choiceMultiComponent\n                case \"IntParam\": return sliderComponent\n                case \"FloatParam\":\n                    if (attribute.desc.semantic === 'color/hue')\n                        return colorHueComponent\n                    return sliderComponent\n                case \"BoolParam\":\n                    return checkboxComponent\n                case \"ListAttribute\":\n                    return listAttributeComponent\n                case \"GroupAttribute\":\n                    return groupAttributeComponent\n                case \"StringParam\":\n                    if (attribute.desc.semantic.includes('multiline'))\n                        return textAreaComponent\n                    return textFieldComponent\n                case \"ColorParam\":\n                    return colorComponent\n                default:\n                    return textFieldComponent\n            }\n        }\n\n        Component {\n            id: notComputedComponent\n            MaterialLabel {\n                anchors.fill: parent\n                text: MaterialIcons.do_not_disturb_alt\n                horizontalAlignment: Text.AlignHCenter\n                verticalAlignment: Text.AlignVCenter\n                padding: 4\n                background: Rectangle {\n                    anchors.fill: parent\n                    border.width: 0\n                    radius: 20\n                    color: Qt.darker(palette.window, 1.1)\n                }\n            }\n        }\n\n        Component {\n            id: pushButtonComponent\n            Button {\n                text: attribute.label\n                enabled: root.editable\n                onClicked: {\n                    attribute.clicked()\n                }\n            }\n        }\n\n        Component {\n            id: textFieldComponent\n            TextField {\n                id: textField\n                readOnly: !root.editable\n                text: attribute.value\n\n                // Don't disable the component to keep interactive features (text selection, context menu...).\n                // Only override the look by using the Disabled palette.\n                SystemPalette { \n                    id: disabledPalette\n                    colorGroup: SystemPalette.Disabled\n                }\n\n                states: [\n                    State {\n                        when: readOnly\n                        PropertyChanges {\n                            target: textField\n                            color: disabledPalette.text\n                        }\n                    }\n                ]\n\n                selectByMouse: true\n                onEditingFinished: setTextFieldAttribute(text)\n                persistentSelection: false\n\n                onAccepted: {\n                    setTextFieldAttribute(text)\n                    parameterLabel.forceActiveFocus()\n                }\n                Keys.onPressed: function(event) {\n                    if ((event.key == Qt.Key_Escape)) {\n                        event.accepted = true\n                        parameterLabel.forceActiveFocus()\n                    }\n                }\n                Component.onDestruction: {\n                    if (activeFocus)\n                        setTextFieldAttribute(text)\n                }\n                DropArea {\n                    enabled: root.editable\n                    anchors.fill: parent\n                    onDropped: function(drop) {\n                        if (drop.hasUrls)\n                            setTextFieldAttribute(Filepath.urlToString(drop.urls[0]))\n                        else if (drop.hasText && drop.text != '')\n                            setTextFieldAttribute(drop.text)\n                    }\n                }\n                onPressed: (event) => {\n                    if(event.button == Qt.RightButton) {\n                        // Keep selection persistent while context menu is open to \n                        // visualize what is being copied or what will be replaced on paste.\n                        persistentSelection = true;\n                        const menu = textFieldMenuComponent.createObject(textField);\n                        menu.popup();\n\n                        if(selectedText === \"\") {\n                            cursorPosition = positionAt(event.x, event.y);\n                        }\n                    }\n                }\n\n                Component {\n                    id: textFieldMenuComponent\n                    Menu {\n                        onOpened: {\n                            // Keep cursor visible to see where pasting would happen.\n                            textField.cursorVisible = true;\n                        }\n                        onClosed: {\n                            // Disable selection persistency behavior once menu is closed and\n                            // give focus back to the parent TextField.\n                            textField.persistentSelection = false;\n                            textField.forceActiveFocus();\n                            destroy();\n                        }\n                        MenuItem {\n                            text: \"Copy\"\n                            enabled: attribute.value != \"\"\n                            onTriggered: {\n                                const hasSelection = textField.selectionStart !== textField.selectionEnd;\n                                if(hasSelection) {\n                                    // Use `TextField.copy` to copy only the current selection.\n                                    textField.copy();\n                                }\n                                else {\n                                    Clipboard.setText(attribute.value);\n                                }\n                            }\n                        }\n                        MenuItem {\n                            text: \"Paste\"\n                            enabled: !readOnly\n                            onTriggered: {\n                                const clipboardText = Clipboard.getText();\n                                if (clipboardText.length === 0) {\n                                    return;\n                                }\n                                const before = textField.text.substr(0, textField.selectionStart);\n                                const after = textField.text.substr(textField.selectionEnd, textField.text.length);\n                                const updatedValue = before + clipboardText + after;\n                                setTextFieldAttribute(updatedValue);\n                                // Set the cursor at the end of the added text\n                                textField.cursorPosition = before.length + clipboardText.length;\n                            }\n                        }\n                    } \n                }\n            }\n        }\n\n        Component {\n            id: textAreaComponent\n\n            Rectangle {\n                // Fixed background for the flickable object\n                color: palette.base\n                width: parent.width\n                height: attribute.desc.semantic.includes(\"large\") ? 400 : 70\n\n                Flickable {\n                    width: parent.width\n                    height: parent.height\n                    contentWidth: width\n                    contentHeight: height\n\n                    ScrollBar.vertical: MScrollBar {}\n\n                    TextArea.flickable: TextArea {\n                        wrapMode: Text.WordWrap\n                        padding: 0\n                        rightPadding: 5\n                        bottomPadding: 2\n                        topPadding: 2\n                        readOnly: !root.editable\n                        onEditingFinished: setTextFieldAttribute(text)\n                        text: attribute.value\n                        selectByMouse: true\n                        onPressed: {\n                            root.forceActiveFocus()\n                        }\n                        Component.onDestruction: {\n                            if (activeFocus)\n                                setTextFieldAttribute(text)\n                        }\n                        DropArea {\n                            enabled: root.editable\n                            anchors.fill: parent\n                            onDropped: {\n                                if (drop.hasUrls)\n                                    setTextFieldAttribute(Filepath.urlToString(drop.urls[0]))\n                                else if (drop.hasText && drop.text != '')\n                                    setTextFieldAttribute(drop.text)\n                            }\n                        }\n                    }\n                }\n            }\n        }\n\n        Component {\n            id: colorComponent\n            RowLayout {\n                CheckBox {\n                    id: colorCheckbox\n                    Layout.alignment: Qt.AlignLeft\n                    checked: attribute.value === \"\" ? false : true\n                    checkable: root.editable\n                    text: \"Custom Color\"\n                    property string previousColor: \"\"\n                    onClicked: {\n                        if (checked) {\n                            if (colorText.text == \"\") {\n                                if (previousColor != \"\")\n                                    _currentScene.setAttribute(attribute, previousColor)\n                                else\n                                    _currentScene.setAttribute(attribute, \"#0000FF\")\n                            }\n                            else\n                                _currentScene.setAttribute(attribute, colorText.text)\n                        } else {\n                            previousColor = attribute.value\n                            _currentScene.setAttribute(attribute, \"\")\n                        }\n                    }\n                }\n                TextField {\n                    id: colorText\n                    Layout.alignment: Qt.AlignLeft\n                    implicitWidth: 100\n                    enabled: colorCheckbox.checked && root.editable\n                    visible: colorCheckbox.checked\n                    text: colorCheckbox.checked ? attribute.value : \"\"\n                    selectByMouse: true\n                    onEditingFinished: setTextFieldAttribute(text)\n                    onAccepted: setTextFieldAttribute(text)\n                    Component.onDestruction: {\n                        if (activeFocus)\n                            setTextFieldAttribute(text)\n                    }\n                }\n\n                Rectangle {\n                    height: colorText.height\n                    width: colorText.width / 2\n                    Layout.alignment: Qt.AlignLeft\n                    visible: colorCheckbox.checked\n                    color: colorCheckbox.checked ? colorDialog.selectedColor : \"\"\n\n                    MouseArea {\n                        enabled: root.editable\n                        anchors.fill: parent\n                        onClicked: colorDialog.open()\n                    }\n                }\n\n                ColorDialog {\n                    id: colorDialog\n                    title: \"Please choose a color\"\n                    selectedColor: colorText.text\n                    onAccepted: {\n                        colorText.text = colorDialog.selectedColor\n                        // Artificially trigger change of attribute value\n                        colorText.editingFinished()\n                        close()\n                    }\n                    onRejected: close()\n                }\n                Item {\n                    // Dummy item to fill out the space if needed\n                    Layout.fillWidth: true\n                }\n            }\n        }\n\n        Component {\n            id: choiceComponent\n\n            AttributeControls.Choice {\n                value: root.attribute.value\n                values: root.attribute.values\n                enabled: root.editable\n\n                onEditingFinished: (value) => {\n                    _currentScene.setAttribute(root.attribute, value)\n                }\n            }\n        }\n\n        Component {\n            id: choiceMultiComponent\n\n            AttributeControls.ChoiceMulti {\n                value: root.attribute.value\n                values: root.attribute.values\n                enabled: root.editable\n                customValueColor: Colors.orange\n                onToggled: (value, checked) => {\n                    var currentValue = root.attribute.value;\n                    if (!checked) {\n                        currentValue.splice(currentValue.indexOf(value), 1);\n                    } else {\n                        currentValue.push(value);\n                    }\n                    _currentScene.setAttribute(attribute, currentValue);\n                }\n            }\n        }\n\n        Component {\n            id: sliderComponent\n            RowLayout {\n                ExpressionTextField {\n                    id: expressionTextField \n                    implicitWidth: 100\n                    Layout.fillWidth: !slider.active\n                    enabled: root.editable\n                    // Cast value to string to avoid intrusive scientific notations on numbers\n                    property string displayValue: String(slider.active && slider.item.pressed ? slider.item.formattedValue : \n                                                        attribute.keyable ? attribute.keyValues.getValueAtKeyOrDefault(_currentScene.selectedViewId) : \n                                                        attribute.value)\n                    text: displayValue\n                    selectByMouse: true\n                    // Note: Use autoScroll as a workaround for alignment\n                    // When the value change keep the text align to the left to be able to read the most important part\n                    // of the number. When we are editing (item is in focus), the content should follow the editing.\n                    autoScroll: activeFocus\n                    isInt: attribute.type === \"FloatParam\" ? false : true\n                    \n                    onEditingFinished: {\n                        if (!hasExprError) {\n                            setTextFieldAttribute(expressionTextField.evaluatedValue)\n                            // Restore binding\n                            expressionTextField.text = Qt.binding(function() { return String(expressionTextField.displayValue); })\n                        }\n                    }\n                    onAccepted: {\n                        if (!hasExprError) {\n                            setTextFieldAttribute(expressionTextField.evaluatedValue)\n                            // Restore binding\n                            expressionTextField.text = Qt.binding(function() { return String(expressionTextField.displayValue); })\n                        }\n                        // When the text is too long, display the left part\n                        // (with the most important values and cut the floating point details)\n                        ensureVisible(0)\n                    }\n                    \n                    Component.onDestruction: {\n                        if (activeFocus) {\n                            if (!hasExprError)\n                                setTextFieldAttribute(expressionTextField.evaluatedValue)\n                        }\n                    }\n                    Component.onCompleted: {\n                        // When the text is too long, display the left part\n                        // (with the most important values and cut the floating point details)\n                        ensureVisible(0)\n                    }\n                }\n\n                Loader {\n                    id: slider\n                    Layout.fillWidth: true\n                    active: attribute.desc.range.length === 3\n                    sourceComponent: Slider {\n                        readonly property int stepDecimalCount: stepSize <  1 ? String(stepSize).split(\".\").pop().length : 0\n                        readonly property real formattedValue: value.toFixed(stepDecimalCount)\n                        enabled: root.editable\n                        value: attribute.keyable ? attribute.keyValues.getValueAtKeyOrDefault(_currentScene.selectedViewId) : attribute.value\n                        from: attribute.desc.range[0]\n                        to: attribute.desc.range[1]\n                        stepSize: attribute.desc.range[2]\n                        snapMode: Slider.SnapAlways\n\n                        onPressedChanged: {\n                            if (!pressed) {\n                                if(attribute.keyable)\n                                    _currentScene.addAttributeKeyValue(attribute, _currentScene.selectedViewId, formattedValue)\n                                else\n                                    _currentScene.setAttribute(attribute, formattedValue)\n                                updateAttributeLabel()\n                            }\n                        }\n                    }\n                }\n            }\n        }\n\n        Component {\n            id: checkboxComponent\n            Row {\n                CheckBox {\n                    enabled: root.editable\n                    checked: attribute.keyable ? attribute.keyValues.getValueAtKeyOrDefault(_currentScene.selectedViewId) : attribute.value\n                    onToggled: {\n                        if(attribute.keyable) \n                        {\n                            const value = attribute.keyValues.getValueAtKeyOrDefault(_currentScene.selectedViewId)\n                            _currentScene.addAttributeKeyValue(attribute, _currentScene.selectedViewId, !value)\n                        }\n                        else \n                        {\n                            _currentScene.setAttribute(attribute, !attribute.value)\n                        }\n                    }\n                }\n            }\n        }\n\n        Component {\n            id: listAttributeComponent\n            ColumnLayout {\n                id: listAttributeLayout\n                width: parent.width\n                property bool expanded: false\n                RowLayout {\n                    spacing: 4\n                    ToolButton {\n                        text: listAttributeLayout.expanded  ? MaterialIcons.keyboard_arrow_down : MaterialIcons.keyboard_arrow_right\n                        font.family: MaterialIcons.fontFamily\n                        onClicked: listAttributeLayout.expanded = !listAttributeLayout.expanded\n                    }\n                    Label {\n                        Layout.alignment: Qt.AlignVCenter\n                        text: attribute.value.count + \" elements\"\n                    }\n                    ToolButton {\n                        text: MaterialIcons.add_circle_outline\n                        font.family: MaterialIcons.fontFamily\n                        font.pointSize: 11\n                        padding: 2\n                        enabled: root.editable\n                        onClicked: _currentScene.appendAttribute(attribute, undefined)\n                    }\n                }\n                ListView {\n                    id: lv\n                    model: listAttributeLayout.expanded ? attribute.value : undefined\n                    visible: model !== undefined && count > 0\n                    implicitHeight: Math.min(contentHeight, 300)\n                    Layout.fillWidth: true\n                    Layout.margins: 4\n                    clip: true\n                    spacing: 4\n\n                    ScrollBar.vertical: MScrollBar { id: sb }\n\n                    delegate: Loader {\n                        active: !objectsHideable\n                            || ((object.isDefault && GraphEditorSettings.showDefaultAttributes || !object.isDefault && GraphEditorSettings.showModifiedAttributes)\n                            && (object.hasAnyInputLinks && GraphEditorSettings.showLinkAttributes || !object.hasAnyInputLinks && GraphEditorSettings.showNotLinkAttributes))\n                        visible: active\n                        sourceComponent: RowLayout {\n                            id: item\n                            property var childAttrib: object\n                            layoutDirection: Qt.RightToLeft\n                            width: lv.width - sb.width\n                            Component.onCompleted: {\n                                var cpt = Qt.createComponent(\"AttributeItemDelegate.qml\")\n                                var obj = cpt.createObject(item,\n                                                        {\n                                                            'attribute': Qt.binding(function() { return item.childAttrib }),\n                                                            'readOnly': Qt.binding(function() { return !root.editable })\n                                                        })\n                                obj.Layout.fillWidth = true\n                                obj.label.text = index\n                                obj.label.horizontalAlignment = Text.AlignHCenter\n                                obj.label.verticalAlignment = Text.AlignVCenter\n                                obj.doubleClicked.connect(function(attr) { root.doubleClicked(attr) })\n                                obj.inAttributeClicked.connect(function(srcItem, mouse, inAttributes) { root.inAttributeClicked(srcItem, mouse, inAttributes) })\n                                obj.outAttributeClicked.connect(function(srcItem, mouse, outAttributes) { root.outAttributeClicked(srcItem, mouse, outAttributes) })\n                            }\n                            ToolButton {\n                                enabled: root.editable\n                                text: MaterialIcons.remove_circle_outline\n                                font.family: MaterialIcons.fontFamily\n                                font.pointSize: 11\n                                padding: 2\n                                ToolTip.text: \"Remove Element\"\n                                ToolTip.visible: hovered\n                                onClicked: _currentScene.removeAttribute(item.childAttrib)\n                            }\n                        }\n                    }\n                }\n            }\n        }\n\n        Component {\n            id: groupAttributeComponent\n            ColumnLayout {\n                id: groupItem\n                Component.onCompleted:  {\n                    var cpt = Qt.createComponent(\"AttributeEditor.qml\");\n                    var obj = cpt.createObject(groupItem,\n                                               {\n                                                   'model': Qt.binding(function() { return attribute.value }),\n                                                   'readOnly': Qt.binding(function() { return root.readOnly }),\n                                                   'labelWidth': 100,  // Reduce label width for children (space gain)\n                                                   'objectsHideable': Qt.binding(function() { return root.objectsHideable }),\n                                                   'filterText': Qt.binding(function() { return root.filterText }),\n                                               })\n                    obj.Layout.fillWidth = true;\n                    obj.attributeDoubleClicked.connect(\n                        function(attr) {\n                            root.doubleClicked(attr)\n                        }\n                    )\n                    obj.inAttributeClicked.connect(\n                        function(srcItem, mouse, inAttributes) {\n                            root.inAttributeClicked(srcItem, mouse, inAttributes)\n                        }\n                    )\n                    obj.outAttributeClicked.connect(\n                        function(srcItem, mouse, outAttributes) {\n                            root.outAttributeClicked(srcItem, mouse, outAttributes)\n                        }\n                    )\n                }\n            }\n        }\n\n        Component {\n            id: colorHueComponent\n            RowLayout {\n                TextField {\n                    implicitWidth: 100\n                    enabled: root.editable\n                    // Cast value to string to avoid intrusive scientific notations on numbers\n                    property string displayValue: String(slider.pressed ? slider.formattedValue : attribute.value)\n                    text: displayValue\n                    selectByMouse: true\n                    validator: DoubleValidator {\n                        locale: 'C'  // Use '.' decimal separator disregarding the system locale\n                    }\n                    onEditingFinished: setTextFieldAttribute(text)\n                    onAccepted: setTextFieldAttribute(text)\n                    Component.onDestruction: {\n                        if (activeFocus)\n                            setTextFieldAttribute(text)\n                    }\n                }\n                Rectangle {\n                    height: slider.height\n                    width: height\n                    color: Qt.hsla(slider.pressed ? slider.formattedValue : attribute.value, 1, 0.5, 1)\n                }\n                Slider {\n                    id: slider\n                    Layout.fillWidth: true\n\n                    readonly property int stepDecimalCount: 2\n                    readonly property real formattedValue: value.toFixed(stepDecimalCount)\n                    enabled: root.editable\n                    value: attribute.value\n                    from: 0\n                    to: 1\n                    stepSize: 0.01\n                    snapMode: Slider.SnapAlways\n                    onPressedChanged: {\n                        if (!pressed)\n                            _currentScene.setAttribute(attribute, formattedValue)\n                    }\n\n                    background: ShaderEffect {\n                        width: slider.availableWidth\n                        height: slider.availableHeight\n                        blending: false\n                        fragmentShader: \"qrc:/shaders/AttributeItemDelegate.frag.qsb\"\n                    }\n                }\n            }\n        }\n    }\n\n    // Add or remove key button for keyable attribute\n    Loader {\n        active: attribute.keyable\n        sourceComponent: MaterialToolButton {\n            font.pointSize: 5\n            padding: 6\n            text: MaterialIcons.circle\n            checkable: true\n            checked: attribute.keyable && attribute.keyValues.hasKey(_currentScene.selectedViewId)\n            enabled: root.editable\n            onClicked: {\n                if (attribute.keyValues.hasKey(_currentScene.selectedViewId))\n                    _currentScene.removeAttributeKey(attribute, _currentScene.selectedViewId)\n                else\n                    _currentScene.addAttributeKeyDefaultValue(attribute, _currentScene.selectedViewId)\n            }\n        }\n    }\n}\n"
  },
  {
    "path": "meshroom/ui/qml/GraphEditor/AttributePin.qml",
    "content": "import QtQuick\nimport QtQuick.Controls\nimport QtQuick.Layouts\n\nimport Utils 1.0\nimport MaterialIcons 2.2\n\n/**\n * The representation of an Attribute on a Node.\n */\n\nRowLayout {\n    id: root\n\n    property var nodeItem\n    property var attribute\n    property bool expanded: false\n    property bool readOnly: false\n    /// Whether to display an output pin for input attribute\n    property bool displayOutputPinForInput: true\n\n    // position of the anchor for attaching and edge to this attribute pin\n    readonly property point inputAnchorPos: Qt.point(inputAnchor.x + inputAnchor.width / 2,\n                                                     inputAnchor.y + inputAnchor.height / 2)\n\n    readonly property point outputAnchorPos: Qt.point(outputAnchor.x + outputAnchor.width / 2,\n                                                      outputAnchor.y + outputAnchor.height / 2)\n\n    readonly property bool isList: attribute && attribute.type === \"ListAttribute\"\n    readonly property bool isGroup: attribute && attribute.type === \"GroupAttribute\"\n    readonly property bool isConnected: attribute.hasAnyInputLinks || attribute.hasAnyOutputLinks\n\n    signal childPinCreated(var childAttribute, var pin)\n    signal childPinDeleted(var childAttribute, var pin)\n\n    signal pressed(var mouse)\n    signal edgeAboutToBeRemoved(var input)\n    signal clicked()\n\n    objectName: attribute ? attribute.name + \".\" : \"\"\n    layoutDirection: Qt.LeftToRight\n    spacing: 3\n\n    ToolTip {\n        text: attribute.fullName + \": \" + attribute.type\n        visible: nameLabel.hovered\n        delay: 500\n\n        x: nameLabel.x\n        y: nameLabel.y + nameLabel.height\n    }\n\n    // Instantiate empty Items for each child attribute\n    Repeater {\n        id: childrenRepeater\n        model: root.isList && !root.attribute.isLink ? root.attribute.value : 0\n        onItemAdded: function(index, item) { childPinCreated(item.childAttribute, root) }\n        onItemRemoved: function(index, item) { childPinDeleted(item.childAttribute, root) }\n        delegate: Item {\n            property var childAttribute: object\n            visible: false\n        }\n    }\n\n    Item {\n        width: childrenRect.width\n        Layout.alignment: Qt.AlignVCenter\n        Layout.fillWidth: true\n        Layout.fillHeight: true\n\n        Rectangle {\n            id: inputAnchor\n            visible: !root.attribute.isOutput\n\n            width: 8\n            height: width\n            radius: root.isList ? 0 : width / 2\n            Layout.alignment: Qt.AlignVCenter\n\n            border.color: {\n                if (innerInputAnchor.hasConnectedChildren)\n                    return Colors.sysPalette.text\n                return Colors.sysPalette.mid\n            }\n            color: Colors.sysPalette.base\n\n            Rectangle {\n                id: innerInputAnchor\n                property bool linkEnabled: true\n                property bool hasConnectedChildren: {\n                    if (!root.isGroup || root.isConnected || !attribute)\n                        return false\n                    for (var i = 0; i < attribute.flatStaticChildren.length; ++i) {\n                        if (attribute.flatStaticChildren[i].hasAnyInputLinks) {\n                            return true\n                        }\n                    }\n                    return false\n                }\n                visible: inputConnectMA.containsMouse || childrenRepeater.count > 0 || hasConnectedChildren ||\n                        (root.attribute && root.attribute.isLink && linkEnabled) || inputConnectMA.drag.active || inputDropArea.containsDrag\n                radius: root.isList ? 0 : 2\n                anchors.fill: parent\n                anchors.margins: 2\n                color: {\n                    if (inputConnectMA.containsMouse || inputConnectMA.drag.active || (inputDropArea.containsDrag && inputDropArea.acceptableDrop))\n                        return Colors.sysPalette.highlight\n                    if (hasConnectedChildren)\n                        return Colors.sysPalette.mid\n                    return Colors.sysPalette.text\n                }\n            }\n\n            DropArea {\n                id: inputDropArea\n\n                property bool acceptableDrop: false\n\n                // Add negative margins for DropArea to make the connection zone easier to reach\n                anchors.fill: parent\n                anchors.margins: -2\n                // Add horizontal negative margins according to the current layout\n                anchors.rightMargin: -root.width * 0.3\n\n                keys: [inputDragTarget.objectName]\n                onEntered: function(drag) {\n                    var validIncomingConnection = drag.source.attribute.validateIncomingConnection(inputDragTarget.attribute)\n                    // Check if attributes are compatible to create a valid connection\n                    if (root.readOnly                                            // Cannot connect on a read-only attribute\n                        || drag.source.objectName != inputDragTarget.objectName  // Not an edge connector\n                        || !validIncomingConnection                              // Connection is not allowed\n                        || drag.source.nodeItem === inputDragTarget.nodeItem     // Connection between attributes of the same node\n                        || drag.source.isList && childrenRepeater.count          // Source/target are lists but target already has children\n                        || 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)\n                    ) {\n                        // Refuse attributes connection\n                        drag.accepted = false\n                    } else if (inputDragTarget.attribute.isLink) {  // Already connected attribute\n                        root.edgeAboutToBeRemoved(inputDragTarget.attribute)\n                    }\n                    inputDropArea.acceptableDrop = drag.accepted\n                }\n\n                onExited: {\n                    if (inputDragTarget.attribute.isLink) {  // Already connected attribute\n                        root.edgeAboutToBeRemoved(undefined)\n                    }\n                    acceptableDrop = false\n                    drag.source.dropAccepted = false\n                }\n\n                onDropped: function(drop) {\n                    root.edgeAboutToBeRemoved(undefined)\n                    _currentScene.addEdge(drag.source.attribute, inputDragTarget.attribute)\n                }\n            }\n\n            Item {\n                id: inputDragTarget\n                objectName: \"edgeConnector\"\n                readonly property string connectorType: \"input\"\n                readonly property alias attribute: root.attribute\n                readonly property alias nodeItem: root.nodeItem\n                readonly property bool isOutput: Boolean(attribute.isOutput)\n                readonly property alias isList: root.isList\n                readonly property alias isGroup: root.isGroup\n                property bool dragAccepted: false\n                anchors.verticalCenter: parent.verticalCenter\n                anchors.horizontalCenter: parent.horizontalCenter\n                width: parent.width\n                height: parent.height\n                Drag.keys: [inputDragTarget.objectName]\n                Drag.active: inputConnectMA.drag.active\n                Drag.hotSpot.x: width * 0.5\n                Drag.hotSpot.y: height * 0.5\n            }\n\n            MouseArea {\n                id: inputConnectMA\n                drag.target: root.attribute.isReadOnly ? undefined : inputDragTarget\n                drag.threshold: 0\n                // Move the edge's tip straight to the current mouse position instead of waiting after the drag operation has started\n                drag.smoothed: false\n                enabled: !root.readOnly\n                anchors.fill: parent\n                hoverEnabled: root.visible\n\n                // Use the same negative margins as DropArea to ease pin selection\n                anchors.margins: inputDropArea.anchors.margins\n                anchors.leftMargin: inputDropArea.anchors.leftMargin\n                anchors.rightMargin: inputDropArea.anchors.rightMargin\n\n                property bool dragTriggered: false  // An edge is being dragged from the input connector\n                property bool isPressed: false  // The mouse has been pressed but not released yet\n                property double initialX: 0.0\n                property double initialY: 0.0\n\n                onPressed: function(mouse) {\n                    root.pressed(mouse)\n                    isPressed = true\n                    initialX = mouse.x\n                    initialY = mouse.y\n                }\n\n                onReleased: {\n                    inputDragTarget.Drag.drop()\n                    isPressed = false\n                    dragTriggered = false\n                }\n\n                onClicked: function() {\n                    root.clicked()\n                }\n\n                onPositionChanged: function(mouse) {\n                    // If there has been a significant move (5px along the -X or -Y axis) while the\n                    // mouse is being pressed, then we can consider being in the dragging state\n                    if (isPressed && (Math.abs(mouse.x - initialX) >= 5.0 || Math.abs(mouse.y - initialY) >= 5.0)) {\n                        dragTriggered = true\n                    }\n                }\n            }\n\n            Edge {\n                id: inputConnectEdge\n                visible: false\n                point1x: inputDragTarget.x + inputDragTarget.width / 2\n                point1y: inputDragTarget.y + inputDragTarget.height / 2\n                point2x: parent.width / 2\n                point2y: parent.width / 2\n                color: palette.highlight\n                thickness: outputDragTarget.dropAccepted ? 2 : 1\n            }\n        }\n    }\n\n    // Attribute name\n    Item {\n        id: nameContainer\n        implicitHeight: childrenRect.height\n        implicitWidth: childrenRect.width\n        Layout.fillWidth: true\n        Layout.fillHeight: true\n        Layout.alignment: Qt.AlignVCenter\n\n        MaterialToolLabel {\n            id: nameLabel\n\n            anchors.fill: parent\n            Layout.fillWidth: true\n            Layout.fillHeight: true\n            anchors.verticalCenter: parent.verticalCenter\n            anchors.margins: 0\n            labelIconRow.layoutDirection: root.attribute.isOutput ? Qt.RightToLeft : Qt.LeftToRight\n            labelIconRow.spacing: 0\n\n            enabled: !root.readOnly\n            visible: true\n\n            // Allow to trigger a change of state once the parent is ready, ensuring the correct width of the\n            // elements upon their first display without waiting for a mouse interaction\n            property bool parentNotReady: nameContainer.width == 0\n\n            property bool hovered: parentNotReady || (inputConnectMA.containsMouse ||\n                                                      inputConnectMA.drag.active ||\n                                                      inputDropArea.containsDrag ||\n                                                      outputConnectMA.containsMouse ||\n                                                      outputConnectMA.drag.active ||\n                                                      outputDropArea.containsDrag)\n\n            labelIconColor: {\n                if ((root.attribute.hasAnyOutputLinks || root.attribute.isLink) && !root.attribute.enabled) {\n                    return Colors.lightgrey\n                } else if (hovered) {\n                    return palette.highlight\n                }\n                return palette.text\n            }\n            labelIconMouseArea.enabled: false  // Prevent mixing mouse interactions between the label and the pin context\n\n            // Text\n            label.text: root.attribute.label\n            label.font.pointSize: 7\n            label.elide: hovered ? Text.ElideNone : Text.ElideMiddle\n            label.horizontalAlignment: root.attribute && root.attribute.isOutput ? Text.AlignRight : Text.AlignLeft\n            label.verticalAlignment: Text.AlignVCenter\n            label.visible: true\n\n            // Icon\n            iconText: {\n                if (root.isGroup) {\n                    return root.expanded ? MaterialIcons.expand_more : MaterialIcons.chevron_right\n                }\n                return \"\"\n            }\n            iconSize: 7\n            icon.horizontalAlignment: root.attribute && root.attribute.isOutput ? Text.AlignRight : Text.AlignLeft\n            icon.verticalAlignment: Text.AlignVCenter\n\n            // Handle tree view for nested attributes\n            property int groupPaddingWidth: root.attribute.depth * 10\n            icon.leftPadding: root.attribute.isOutput ? 0 : groupPaddingWidth\n            icon.rightPadding: root.attribute.isOutput ? groupPaddingWidth : 0\n        }\n    }\n\n    Rectangle {\n        id: outputAnchor\n\n        visible: root.displayOutputPinForInput || root.attribute.isOutput\n        width: 8\n        height: width\n        radius: root.isList ? 0 : width / 2\n\n        Layout.alignment: Qt.AlignVCenter\n\n        border.color: {\n            if (innerOutputAnchor.hasConnectedChildren)\n                return Colors.sysPalette.text\n            return Colors.sysPalette.mid\n        }\n        color: Colors.sysPalette.base\n\n        Rectangle {\n            id: innerOutputAnchor\n            property bool linkEnabled: true\n            property bool hasConnectedChildren: {\n                if (!root.isGroup || root.isConnected)\n                    return false\n                for (var i = 0; i < attribute.flatStaticChildren.length; ++i) {\n                    if (attribute.flatStaticChildren[i].hasAnyOutputLinks) {\n                        return true\n                    }\n                }\n                return false\n            }\n            visible: (root.attribute.hasAnyOutputLinks && linkEnabled) || outputConnectMA.containsMouse || outputConnectMA.drag.active || outputDropArea.containsDrag || hasConnectedChildren\n            radius: root.isList ? 0 : 2\n            anchors.fill: parent\n            anchors.margins: 2\n            color: {\n                if (root.attribute.enabled && (outputConnectMA.containsMouse || outputConnectMA.drag.active ||\n                                               (outputDropArea.containsDrag && outputDropArea.acceptableDrop)))\n                    return Colors.sysPalette.highlight\n                if (hasConnectedChildren)\n                    return Colors.sysPalette.mid\n                return Colors.sysPalette.text\n            }\n        }\n\n        DropArea {\n            id: outputDropArea\n\n            property bool acceptableDrop: false\n\n            // Add negative margins for DropArea to make the connection zone easier to reach\n            anchors.fill: parent\n            anchors.margins: -2\n            // Add horizontal negative margins according to the current layout\n            anchors.leftMargin: -root.width * 0.2\n\n            keys: [outputDragTarget.objectName]\n            onEntered: function(drag) {\n                var validIncomingConnection = outputDragTarget.attribute.validateIncomingConnection(drag.source.attribute)\n                // Check if attributes are compatible to create a valid connection\n                if (drag.source.objectName != outputDragTarget.objectName   // Not an edge connector\n                    || !validIncomingConnection                             // Connection is not allowed\n                    || drag.source.nodeItem === outputDragTarget.nodeItem   // Connection between attributes of the same node\n                    || (!drag.source.isList && outputDragTarget.isList)     // Connection between a list and a simple attribute\n                    || (drag.source.isList && childrenRepeater.count)       // Source/target are lists but target already has children\n                    || drag.source.connectorType === \"output\"               // Refuse to connect an output pin on another one\n                   ) {\n                    // Refuse attributes connection\n                    drag.accepted = false\n                } else if (drag.source.attribute.isLink) {  // Already connected attribute\n                    root.edgeAboutToBeRemoved(drag.source.attribute)\n                }\n                outputDropArea.acceptableDrop = drag.accepted\n            }\n            onExited: {\n                root.edgeAboutToBeRemoved(undefined)\n                acceptableDrop = false\n            }\n\n            onDropped: function(drop) {\n                root.edgeAboutToBeRemoved(undefined)\n                _currentScene.addEdge(outputDragTarget.attribute, drag.source.attribute)\n            }\n        }\n\n        Item {\n            id: outputDragTarget\n            objectName: \"edgeConnector\"\n            readonly property string connectorType: \"output\"\n            readonly property alias attribute: root.attribute\n            readonly property alias nodeItem: root.nodeItem\n            readonly property bool isOutput: Boolean(attribute.isOutput)\n            readonly property alias isList: root.isList\n            readonly property alias isGroup: root.isGroup\n            property bool dropAccepted: false\n            anchors.horizontalCenter: parent.horizontalCenter\n            anchors.verticalCenter: parent.verticalCenter\n            width: parent.width\n            height: parent.height\n            Drag.keys: [outputDragTarget.objectName]\n            Drag.active: outputConnectMA.drag.active\n            Drag.hotSpot.x: width * 0.5\n            Drag.hotSpot.y: height * 0.5\n        }\n\n        MouseArea {\n            id: outputConnectMA\n            drag.target: outputDragTarget\n            drag.threshold: 0\n            // Move the edge's tip straight to the current mouse position instead of waiting after the drag operation has started\n            drag.smoothed: false\n            anchors.fill: parent\n            // Use the same negative margins as DropArea to ease pin selection\n            anchors.margins: outputDropArea.anchors.margins\n            anchors.leftMargin: outputDropArea.anchors.leftMargin\n            anchors.rightMargin: outputDropArea.anchors.rightMargin\n\n            hoverEnabled: root.visible\n\n            property bool dragTriggered: false  // An edge is being dragged from the output connector\n            property bool isPressed: false   // The mouse has been pressed but not released yet\n            property double initialX: 0.0\n            property double initialY: 0.0\n\n            onPressed: function(mouse) {\n                root.pressed(mouse)\n                isPressed = true\n                initialX = mouse.x\n                initialY = mouse.y\n            }\n\n            onReleased: function(mouse) {\n                outputDragTarget.Drag.drop()\n                isPressed = false\n                dragTriggered = false\n            }\n\n            onClicked: function() {\n                root.clicked()\n            }\n\n            onPositionChanged: function(mouse) {\n                // If there has been a significant move (5px along the -X or -Y axis) while the mouse is being\n                // pressed, then we can consider being in the dragging state.\n                if (isPressed && (Math.abs(mouse.x - initialX) >= 5.0 || Math.abs(mouse.y - initialY) >= 5.0)) {\n                    dragTriggered = true\n                }\n            }\n        }\n\n        Edge {\n            id: outputConnectEdge\n            visible: false\n            point1x: parent.width / 2\n            point1y: parent.width / 2\n            point2x: outputDragTarget.x + outputDragTarget.width / 2\n            point2y: outputDragTarget.y + outputDragTarget.height / 2\n            color: palette.highlight\n            thickness: outputDragTarget.dropAccepted ? 2 : 1\n        }\n    }\n\n    state: inputConnectMA.dragTriggered ? \"DraggingInput\" : outputConnectMA.dragTriggered ? \"DraggingOutput\" : \"\"\n\n    states: [\n        State {\n            name: \"\"\n            AnchorChanges {\n                target: outputDragTarget\n                anchors.horizontalCenter: outputAnchor.horizontalCenter\n                anchors.verticalCenter: outputAnchor.verticalCenter\n            }\n            AnchorChanges {\n                target: inputDragTarget\n                anchors.horizontalCenter: inputAnchor.horizontalCenter\n                anchors.verticalCenter: inputAnchor.verticalCenter\n            }\n            PropertyChanges {\n                target: inputDragTarget\n                x: 0\n                y: 0\n            }\n            PropertyChanges {\n                target: outputDragTarget\n                x: 0\n                y: 0\n            }\n        },\n\n        State {\n            name: \"DraggingInput\"\n            AnchorChanges {\n                target: inputDragTarget\n                anchors.horizontalCenter: undefined\n                anchors.verticalCenter: undefined\n            }\n            PropertyChanges {\n                target: inputConnectEdge\n                z: 100\n                visible: true\n            }\n            StateChangeScript {\n                script: {\n                    // Add the right offset if the initial click is not exactly at the center of the connection circle.\n                    var pos = inputDragTarget.mapFromItem(inputConnectMA, inputConnectMA.mouseX, inputConnectMA.mouseY);\n                    inputDragTarget.x = pos.x - inputDragTarget.width / 2;\n                    inputDragTarget.y = pos.y - inputDragTarget.height / 2;\n                }\n            }\n        },\n        State {\n            name: \"DraggingOutput\"\n            AnchorChanges {\n                target: outputDragTarget\n                anchors.horizontalCenter: undefined\n                anchors.verticalCenter: undefined\n            }\n            PropertyChanges {\n                target: outputConnectEdge\n                z: 100\n                visible: true\n            }\n            StateChangeScript {\n                script: {\n                    var pos = outputDragTarget.mapFromItem(outputConnectMA, outputConnectMA.mouseX, outputConnectMA.mouseY);\n                    outputDragTarget.x = pos.x - outputDragTarget.width / 2;\n                    outputDragTarget.y = pos.y - outputDragTarget.height / 2;\n                }\n            }\n        }\n    ]\n}\n"
  },
  {
    "path": "meshroom/ui/qml/GraphEditor/Backdrop.qml",
    "content": "import QtQuick\nimport QtQuick.Controls\nimport QtQuick.Layouts\nimport Qt5Compat.GraphicalEffects\n\nimport Utils 1.0\n\nimport Meshroom.Helpers\n\n/**\n * Visual representation of a Graph Backdrop Node.\n */\n\nItem {\n    id: root\n\n    // The underlying Node object\n    property variant node\n\n    // Mouse related states\n    property bool mainSelected: false\n    property bool selected: false\n    property bool hovered: false\n\n    // The item instantiating the delegates\n    property Item modelInstantiator: undefined\n\n    // Node children for the Backdrop\n    property var children: []\n    property var childrenIndices: []\n\n    property bool ctrlHeld: false\n    property bool dragging: headerMouseArea.drag.active\n    property bool resizing: leftDragger.drag.active || topDragger.drag.active\n    // Combined x and y\n    property point position: Qt.point(x, y)\n    // Styling\n    property color shadowColor: \"#000000\"\n    readonly property color defaultColor: node.color === \"\" ? \"#fffb85\" : node.color\n    property color baseColor: defaultColor\n\n    readonly property int minimumWidth: 200\n    readonly property int minimumHeight: 200\n\n    // Identifies this delegate as a backdrop node (used e.g. for selection rect intersection tests)\n    readonly property bool isBackdropNode: true\n    // Height of the titlebar, used for selection rect computation\n    readonly property real headerHeight: header.height\n\n    property point mousePosition: Qt.point(mouseArea.mouseX, mouseArea.mouseY)\n\n    // Mouse interaction related signals\n    signal pressed(var mouse)\n    signal released(var mouse)\n    signal clicked(var mouse)\n    signal doubleClicked(var mouse)\n    signal moved(var position)\n    signal entered()\n    signal exited()\n\n    // Size signal\n    signal resized(var width, var height)\n    signal resizedAndMoved(var width, var height, var position)\n\n    // Already connected attribute with another edge in DropArea\n    signal edgeAboutToBeRemoved(var input)\n\n    // Emitted when child attribute pins are created\n    signal attributePinCreated(var attribute, var pin)\n    // Emitted when child attribute pins are deleted\n    signal attributePinDeleted(var attribute, var pin)\n\n    // Use node name as object name to simplify debugging\n    objectName: node ? node.name : \"\"\n\n    // initialize position with node coordinates\n    x: root.node ? root.node.x : undefined\n    y: root.node ? root.node.y : undefined\n\n    // The backdrop node always needs to be at the back\n    z: -1\n\n    width: root.node ? root.node.nodeWidth : 300\n    height: root.node ? root.node.nodeHeight : 200\n\n    implicitHeight: childrenRect.height\n\n    SystemPalette { id: activePalette }\n\n    Connections {\n        target: root.node\n\n        function onPositionChanged() {\n            root.x = root.node.x\n            root.y = root.node.y\n        }\n\n        function onInternalAttributesChanged() {\n            root.width = root.node.nodeWidth\n            root.height = root.node.nodeHeight\n        }\n    }\n\n    // When the node is selected, update the children for it\n    // For node to consider another node, it needs to be fully inside the backdrop area\n    onSelectedChanged: {\n        if (selected) {\n            updateChildren()\n        }\n    }\n\n    onPressed: {\n        updateChildren()\n    }\n\n    function updateChildren() {\n        let indices = []\n        let nodes = []\n        const backdropRect = Qt.rect(root.node.x, root.node.y, root.node.nodeWidth, root.node.nodeHeight)\n\n        for (var i = 0; i < modelInstantiator.count; ++i) {\n            const delegate = modelInstantiator.getItemAt(i)\n            if (!delegate || delegate === this)\n                continue\n\n            const delegateRect = Qt.rect(delegate.x, delegate.y, delegate.width, delegate.height)\n            if (Geom2D.rectRectFullIntersect(backdropRect, delegateRect)) {\n                indices.push(i)\n                nodes.push(delegate)\n            }\n        }\n        childrenIndices = indices\n        children = nodes\n    }\n\n    function getChildrenNodes(refresh = false) {\n        // Returns the current nodes which are a part of the Backdrop\n        if (refresh) {\n            updateChildren()\n        }\n        return children\n    }\n\n    function getChildrenIndices(refresh = false) {\n        // Returns the current nodes' indices which are a part of the Backdrop\n        if (refresh) {\n            updateChildren()\n        }\n        return childrenIndices\n    }\n\n    // Main Layout\n    MouseArea {\n        id: mouseArea\n        width: root.width\n        height: root.height\n        acceptedButtons: Qt.NoButton\n        hoverEnabled: true\n        onEntered: root.entered()\n        onExited: root.exited()\n\n        cursorShape: Qt.ArrowCursor\n\n        // --- Backdrop Resize Controls\n        // Resize: diagonal bottom-right\n        Rectangle {\n            width: 8\n            height: 8\n\n            color: baseColor\n            opacity: 0\n\n            anchors.horizontalCenter: parent.right\n            anchors.verticalCenter: parent.bottom\n\n            MouseArea {\n                id: diagonalDragger\n\n                cursorShape: Qt.SizeFDiagCursor\n                anchors.fill: parent\n\n                drag {\n                    target: parent\n                    axis: Drag.XAndYAxis\n                }\n\n                onMouseXChanged: {\n                    if (drag.active) {\n                        // Update the area width\n                        root.width = root.width + mouseX\n\n                        // Ensure we have a minimum width always\n                        if (root.width < root.minimumWidth) {\n                            root.width = root.minimumWidth\n                        }\n                    }\n                }\n\n                onMouseYChanged: {\n                    if (drag.active) {\n                        // Update the height\n                        root.height = root.height + mouseY\n\n                        // Ensure a minimum height\n                        if (root.height < root.minimumHeight) {\n                            root.height = root.minimumHeight\n                        }\n                    }\n                }\n\n                onReleased: {\n                    root.resized(root.width, root.height)\n                }\n            }\n        }\n\n        // Resize: right side\n        Rectangle {\n            width: 4\n            height: nodeContent.height\n\n            color: baseColor\n            opacity: 0\n\n            anchors.horizontalCenter: parent.right\n\n            // This mouse area serves as the dragging rectangle    \n            MouseArea {\n                id: rightDragger\n\n                cursorShape: Qt.SizeHorCursor\n                anchors.fill: parent\n\n                drag {\n                    target: parent\n                    axis: Drag.XAxis\n                }\n\n                onMouseXChanged: {\n                    if (drag.active) {\n                        // Update the area width\n                        root.width = root.width + mouseX\n\n                        // Ensure we have a minimum width always\n                        if (root.width < root.minimumWidth) {\n                            root.width = root.minimumWidth\n                        }\n                    }\n                }\n\n                onReleased: {\n                    root.resized(root.width, nodeContent.height)\n                }\n            }\n        }\n\n        // Resize: left side\n        Rectangle {\n            width: 4\n            height: nodeContent.height\n\n            color: baseColor\n            opacity: 0\n\n            anchors.horizontalCenter: parent.left\n\n            // This mouse area serves as the dragging rectangle\n            MouseArea {\n                id: leftDragger\n\n                cursorShape: Qt.SizeHorCursor\n                anchors.fill: parent\n\n                drag {\n                    target: parent\n                    axis: Drag.XAxis\n                }\n\n                onMouseXChanged: {\n                    if (drag.active) {\n                        // Width of the Area\n                        let w = 0\n\n                        // Update the area width\n                        w = root.width - mouseX\n\n                        // Ensure we have a minimum width always\n                        if (w > root.minimumWidth) {\n                            // Update the node's x position and the width\n                            root.x = root.x + mouseX\n                            root.width = w\n                        }\n                    }\n                }\n\n                onReleased: {\n                    // Dragging from the left moves the node as well\n                    root.resizedAndMoved(root.width, root.height, Qt.point(root.x, root.y))\n                }\n            }\n        }\n\n        // Resize: bottom\n        Rectangle {\n            width: mouseArea.width\n            height: 4\n\n            color: baseColor\n            opacity: 0\n\n            anchors.verticalCenter: nodeContent.bottom\n\n            MouseArea {\n                id: bottomDragger\n\n                cursorShape: Qt.SizeVerCursor\n                anchors.fill: parent\n\n                drag {\n                    target: parent\n                    axis: Drag.YAxis\n                }\n\n                onMouseYChanged: {\n                    if (drag.active) {\n                        // Update the height\n                        root.height = root.height + mouseY\n\n                        // Ensure a minimum height\n                        if (root.height < root.minimumHeight) {\n                            root.height = root.minimumHeight\n                        }\n                    }\n                }\n\n                onReleased: {\n                    root.resized(mouseArea.width, root.height)\n                }\n            }\n        }\n\n        // Resize: top\n        Rectangle {\n            width: mouseArea.width\n            height: 4\n\n            color: baseColor\n            opacity: 0\n\n            anchors.verticalCenter: parent.top\n\n            MouseArea {\n                id: topDragger\n\n                cursorShape: Qt.SizeVerCursor\n                anchors.fill: parent\n\n                drag {\n                    target: parent\n                    axis: Drag.YAxis\n                }\n\n                onMouseYChanged: {\n                    if (drag.active) {\n                        let h = root.height - mouseY\n\n                        // Ensure a minimum height\n                        if (h > root.minimumHeight) {\n                            // Update the node's y position and the height\n                            root.y = root.y + mouseY\n                            root.height = h\n                        }\n                    }\n                }\n\n                onReleased: {\n                    // Dragging from the top moves the node as well\n                    root.resizedAndMoved(root.width, root.height, Qt.point(root.x, root.y))\n                }\n            }\n        }\n\n        // Selection border\n        Rectangle {\n            anchors.fill: nodeContent\n            anchors.margins: -border.width\n            visible: root.mainSelected || root.hovered || root.selected\n            border.width: {\n                if (root.mainSelected)\n                    return 3\n                if (root.selected)\n                    return 2.5\n                return 2\n            }\n            border.color: {\n                if (root.mainSelected)\n                    return activePalette.highlight\n                if (root.selected)\n                    return Qt.darker(activePalette.highlight, 1.2)\n                return Qt.lighter(activePalette.base, 3)\n            }\n            opacity: 0.9\n            radius: background.radius + border.width\n            color: \"transparent\"\n        }\n\n        Rectangle {\n            id: background\n            anchors.fill: nodeContent\n            color: Qt.darker(baseColor, 1.2)\n            layer.enabled: true\n            layer.effect: DropShadow { radius: 3; color: shadowColor }\n            radius: 3\n            opacity: 0.7\n        }\n\n        Rectangle {\n            id: nodeContent\n            width: parent.width\n            height: parent.height\n            color: \"transparent\"\n\n            // Data Layout\n            Column {\n                id: body\n                width: parent.width\n\n                // Header\n                Rectangle {\n                    id: header\n                    width: parent.width\n                    height: headerLayout.height\n                    color: root.baseColor\n                    radius: background.radius\n\n                    // Fill header's bottom radius\n                    Rectangle {\n                        width: parent.width\n                        height: parent.radius\n                        anchors.bottom: parent.bottom\n                        color: parent.color\n                        z: -1\n                    }\n\n                    // Header Layout\n                    RowLayout {\n                        id: headerLayout\n                        width: parent.width\n                        spacing: 0\n\n                        // Node Name\n                        Label {\n                            id: nodeLabel\n                            Layout.fillWidth: true\n                            text: node ? node.label : \"\"\n                            padding: 4\n                            color: \"#2b2b2b\"\n                            elide: Text.ElideMiddle\n                            font.pointSize: 8\n                        }\n                    }\n\n                    // Header-only MouseArea: handles drag, click, and selection.\n                    // Only the titlebar allows moving the backdrop to preserve standard\n                    // rectangle selection behavior on the backdrop body.\n                    MouseArea {\n                        id: headerMouseArea\n                        anchors.fill: parent\n                        drag.target: ctrlHeld ? undefined : root\n                        // Small drag threshold to avoid moving the node by mistake\n                        drag.threshold: 2\n                        hoverEnabled: true\n                        acceptedButtons: Qt.LeftButton | Qt.RightButton\n                        onPressed: (mouse) => root.pressed(mouse)\n                        onReleased: (mouse) => root.released(mouse)\n                        onClicked: (mouse) => root.clicked(mouse)\n                        onDoubleClicked: (mouse) => root.doubleClicked(mouse)\n                        drag.onActiveChanged: {\n                            if (!drag.active) {\n                                root.moved(Qt.point(root.x, root.y))\n                            }\n                        }\n\n                        cursorShape: drag.active ? Qt.ClosedHandCursor : Qt.OpenHandCursor\n                    }\n                }\n\n                // Vertical Spacer\n                Item {\n                    width: parent.width\n                    height: 2\n                }\n\n                // Node Comments Text which is visible on the backdrop\n                Text {\n                    visible: node.comment\n                    text: node.comment\n                    font.pointSize: node.fontSize\n                    color: node.fontColor === \"\" ? \"#000000\" : node.fontColor\n\n                    y: header.height\n\n                    padding: 4\n\n                    width: parent.width\n                    height: nodeContent.height - header.height\n\n                    wrapMode: Text.Wrap\n                    elide: Text.ElideRight\n                }\n            }\n        }\n    }\n}\n"
  },
  {
    "path": "meshroom/ui/qml/GraphEditor/ChunksListView.qml",
    "content": "import QtQuick\nimport QtQuick.Controls\nimport QtQuick.Layouts\n\nimport Utils 1.0\n\n/**\n * ChunksListView\n */\n\nColumnLayout {\n    id: root\n\n    property var uigraph: null\n    property variant chunks\n    property int currentIndex: 0\n    property variant currentChunk: (chunks && currentIndex >= 0) ? chunks.at(currentIndex) : undefined\n\n    onChunksChanged: {\n        // When the list changes, ensure the current index is in the new range\n        if (!chunks)\n            currentIndex = -1\n        else if (currentIndex >= chunks.count)\n            currentIndex = chunks.count-1\n    }\n\n    // chunksSummary is in sync with allChunks button (but not directly accessible as it is in a Component)\n    property bool chunksSummary: (currentIndex === -1)\n\n    width: 60\n\n    ListView {\n        id: chunksLV\n        Layout.fillWidth: true\n        Layout.fillHeight: true\n\n        model: root.chunks\n\n        highlightFollowsCurrentItem: (root.chunksSummary === false)\n        keyNavigationEnabled: true\n        focus: true\n        currentIndex: root.currentIndex\n        onCurrentIndexChanged: {\n            if (chunksLV.currentIndex !== root.currentIndex) {\n                // When the list is resized, the currentIndex is reset to 0.\n                // So here we force it to keep the binding.\n                chunksLV.currentIndex = Qt.binding(function() { return root.currentIndex })\n            }\n        }\n\n        header: Component {\n            Button {\n                id: allChunks\n                text: \"Chunks\"\n                width: parent.width\n                flat: true\n                checkable: true\n                property bool summaryEnabled: root.chunksSummary\n                checked: summaryEnabled\n                onSummaryEnabledChanged: {\n                    checked = summaryEnabled\n                }\n                onClicked: {\n                    root.currentIndex = -1\n                    checked = true\n                }\n            }\n        }\n        highlight: Component {\n            Rectangle {\n                visible: true  // !root.chunksSummary\n                color: activePalette.highlight\n                opacity: 0.3\n                z: 2\n            }\n        }\n        highlightMoveDuration: 0\n        highlightResizeDuration: 0\n\n        delegate: ItemDelegate {\n            id: chunkDelegate\n            property var chunk: object\n            text: index\n            width: ListView.view.width\n            leftPadding: 8\n            onClicked: {\n                chunksLV.forceActiveFocus()\n                root.currentIndex = index\n            }\n            Rectangle {\n                width: 4\n                height: parent.height\n                color: Colors.getChunkColor(parent.chunk)\n            }\n        }\n    }\n\n    Connections {\n        target: _currentScene\n        function onSelectedChunkChanged() {\n            for (var i = 0; i < root.chunks.count; i++) {\n                if (_currentScene.selectedChunk === root.chunks.at(i)) {\n                    root.currentIndex = i\n                    break;\n                }\n            }\n        }\n        ignoreUnknownSignals: true\n    }\n}\n"
  },
  {
    "path": "meshroom/ui/qml/GraphEditor/CompatibilityBadge.qml",
    "content": "import QtQuick\nimport QtQuick.Controls\nimport QtQuick.Layouts\n\nimport MaterialIcons 2.2\n\n/**\n * Node Badge to inform about compatibility issues\n * Provides 2 delegates (can be set using sourceComponent property):\n *     - iconDelegate (default): icon + tooltip with information about the issue\n *     - bannerDelegate: banner with issue info + upgrade request button\n */\n\nLoader {\n    id: root\n\n    property bool canUpgrade\n    property string issueDetails\n    property color color: canUpgrade ? \"#E68A00\" : \"#F44336\"\n\n    signal upgradeRequest()\n\n    sourceComponent: iconDelegate\n\n    property Component iconDelegate: Component {\n\n        Label {\n            text: MaterialIcons.warning\n            font.family: MaterialIcons.fontFamily\n            font.pointSize: 12\n            color: root.color\n\n            MouseArea {\n                anchors.fill: parent\n                hoverEnabled: true\n                onPressed: mouse.accepted = false\n                ToolTip.text: issueDetails\n                ToolTip.visible: containsMouse\n            }\n        }\n    }\n\n    property Component bannerDelegate: Component {\n\n        Pane {\n            padding: 6\n            clip: true\n            background: Rectangle { color: root.color }\n\n            RowLayout {\n                width: parent.width\n                Column {\n                    Layout.fillWidth: true\n                    Label {\n                        width: parent.width\n                        elide: Label.ElideMiddle\n                        font.bold: true\n                        text: \"Compatibility issue\"\n                        color: \"white\"\n                    }\n                    Label {\n                        width: parent.width\n                        elide: Label.ElideMiddle\n                        text: root.issueDetails\n                        color: \"white\"\n                    }\n                }\n                Button {\n                    visible: root.canUpgrade && (parent.width > width) ? 1 : 0\n                    palette.window: root.color\n                    palette.button: Qt.darker(root.color, 1.2)\n                    palette.buttonText: \"white\"\n                    text: \"Upgrade\"\n                    onClicked: upgradeRequest()\n                }\n            }\n        }\n    }\n}\n"
  },
  {
    "path": "meshroom/ui/qml/GraphEditor/CompatibilityManager.qml",
    "content": "import QtQuick\nimport QtQuick.Controls\nimport QtQuick.Layouts\n\nimport MaterialIcons 2.2\nimport Controls 1.0\nimport Utils 1.0\n\n/**\n * CompatibilityManager summarizes and allows to resolve compatibility issues.\n */\n\nMessageDialog {\n    id: root\n\n    // The UIGraph instance\n    property var uigraph\n    // Alias to underlying compatibilityNodes model\n    readonly property var nodesModel: uigraph ? uigraph.graph.compatibilityNodes : undefined\n    // The total number of compatibility issues\n    readonly property int issueCount: (nodesModel !== undefined && nodesModel !== null) ? nodesModel.count : 0\n    // The number of CompatibilityNodes that can be upgraded\n    readonly property int upgradableCount: {\n        var count = 0\n        for (var i = 0; i < issueCount; ++i) {\n            if (nodesModel.at(i).canUpgrade)\n                count++;\n        }\n        return count\n    }\n\n    // Override MessageDialog.getAsString to add compatibility report\n    function getAsString() {\n        var t = asString + \"\\n\"\n        t += '-------------------------\\n'\n        t += \"Node | Issue | Upgradable\\n\"\n        t += '-------------------------\\n'\n        for (var i = 0; i < issueCount; ++i) {\n            var n = nodesModel.at(i)\n             t += n.nodeType + \" | \" + n.issueDetails +  \" | \" + n.canUpgrade + \"\\n\"\n        }\n        t += \"\\n\" + questionLabel.text\n        return t\n    }\n\n    signal upgradeDone()\n\n    title: \"Compatibility issues detected\"\n    text: \"This project contains \" + issueCount + \" node(s) incompatible with the current version of Meshroom.\"\n    detailedText: {\n        let releaseVersion = uigraph ? uigraph.graph.fileReleaseVersion : \"0.0\"\n        return \"Project was created with Meshroom \" + releaseVersion + \".\"\n    }\n\n    helperText: upgradableCount ?\n                upgradableCount + \" node(s) can be upgraded but this might invalidate already computed data.\\n\"\n                + \"This operation is undoable and can also be done manually in the Graph Editor.\"\n                : \"\"\n\n    content: ColumnLayout {\n        spacing: 16\n\n        ListView {\n            id: listView\n            Layout.fillWidth: true\n            Layout.maximumHeight: 300\n            implicitHeight: contentHeight\n            clip: true\n            model: nodesModel\n\n            property int longestLabel: {\n                var longest = 0\n                for (var i = 0; i < issueCount; ++i) {\n                    var n = nodesModel.at(i)\n                    if (n.defaultLabel.length > longest)\n                        longest = n.defaultLabel.length\n                }\n                return longest\n            }\n\n            property int upgradableLabelWidth: {\n                return \"Upgradable\".length * root.textMetrics.width\n            }\n\n            ScrollBar.vertical: MScrollBar { id: scrollbar }\n\n            spacing: 4\n            headerPositioning: ListView.OverlayHeader\n            header: Pane {\n                z: 2\n                width: ListView.view.width\n                padding: 6\n                background: Rectangle { color: Qt.darker(parent.palette.window, 1.15) }\n                RowLayout {\n                    width: parent.width\n                    Label { text: \"Node\"; Layout.preferredWidth: listView.longestLabel * root.textMetrics.width; font.bold: true }\n                    Label { text: \"Issue\"; Layout.fillWidth: true; font.bold: true }\n                    Label { text: \"Upgradable\"; Layout.preferredWidth: listView.upgradableLabelWidth; font.bold: true }\n                }\n            }\n\n            delegate: RowLayout {\n                id: compatibilityNodeDelegate\n\n                property var node: object\n\n                width: ListView.view.width - 12\n                anchors.horizontalCenter: parent != null ? parent.horizontalCenter : undefined\n\n                Label {\n                    Layout.preferredWidth: listView.longestLabel * root.textMetrics.width\n                    text: compatibilityNodeDelegate.node ? compatibilityNodeDelegate.node.defaultLabel : \"\"\n                }\n                Label {\n                    Layout.fillWidth: true\n                    text: compatibilityNodeDelegate.node ? compatibilityNodeDelegate.node.issueDetails : \"\"\n                }\n                Label {\n                    Layout.preferredWidth: listView.upgradableLabelWidth\n                    horizontalAlignment: Text.AlignHCenter\n                    text: compatibilityNodeDelegate.node && compatibilityNodeDelegate.node.canUpgrade ? MaterialIcons.check : MaterialIcons.clear\n                    color: compatibilityNodeDelegate.node && compatibilityNodeDelegate.node.canUpgrade ? \"#4CAF50\" : \"#F44336\"\n                    font.family: MaterialIcons.fontFamily\n                    font.pointSize: 14\n                    font.bold: true\n                }\n            }\n        }\n\n        Label {\n            id: questionLabel\n            text: upgradableCount ? \"Upgrade all possible nodes to current version?\"\n                                  : \"Those nodes cannot be upgraded, remove them manually if needed.\"\n        }\n    }\n\n    standardButtons: upgradableCount ? Dialog.Yes | Dialog.No : Dialog.Ok\n\n    icon {\n        text: MaterialIcons.warning\n        color: \"#FF9800\"\n    }\n\n    onAccepted: {\n        if (upgradableCount) {\n            uigraph.upgradeAllNodes()\n            upgradeDone()\n        }\n    }\n}\n"
  },
  {
    "path": "meshroom/ui/qml/GraphEditor/Edge.qml",
    "content": "import QtQuick\nimport QtQuick.Controls\nimport QtQuick.Shapes 1.6\n\nimport GraphEditor 1.0\nimport MaterialIcons 2.2\n\n/**\n * A cubic spline representing an edge, going from point1 to point2, providing mouse interaction.\n */\n\nItem {\n    id: root\n\n    property var edge\n    property real point1x\n    property real point1y\n    property real point2x\n    property real point2y\n    property alias thickness: path.strokeWidth\n    property alias color: path.strokeColor\n    property bool isForLoop: false\n    property int loopSize: 0\n    property int iteration: 0\n\n    // Note: edgeArea is destroyed before path, so we need to test if not null to avoid warnings.\n    readonly property bool containsMouse: (loopArea && loopArea.containsMouse) || (edgeArea && edgeArea.containsMouse)\n\n    signal pressed(var event)\n    signal released(var event)\n\n    x: point1x\n    y: point1y\n    width: point2x - point1x\n    height: point2y - point1y\n\n    property real startX: 0\n    property real startY: 0\n    property real endX: width\n    property real endY: height\n\n\n    function intersectsSegment(p1, p2) {\n        /**\n         * Detects whether a line along the given rects diagonal intersects with the edge mouse area.\n         */\n        // The edgeArea is within the parent Item and its bounds and position are relative to its parent\n        // Map the original rect to the coordinates of the edgeArea by subtracting the parent's coordinates from the rect\n        // This mapped rect would ensure that the rect coordinates map to 0 of the edge area\n        return edgeArea.intersectsSegment(Qt.point(p1.x - x, p1.y - y), Qt.point(p2.x - x, p2.y - y));\n    }\n\n    Shape {\n        anchors.fill: parent\n        // Cause rendering artifacts when enabled (and do not support hot reload really well)\n        vendorExtensionsEnabled: false\n        opacity: 0.7\n\n        ShapePath {\n            id: path\n            startX: root.startX\n            startY: root.startY\n            fillColor: \"transparent\"\n\n            strokeColor: \"#3E3E3E\"\n            strokeStyle: edge !== undefined && ((edge.src !== undefined && edge.src.isOutput) || edge.dst === undefined) ? ShapePath.SolidLine : ShapePath.DashLine\n            strokeWidth: 1\n            // Final visual width of this path (never below 1)\n            readonly property real visualWidth: Math.max(strokeWidth, 1)\n            dashPattern: [6 / visualWidth, 4 / visualWidth]\n            capStyle: ShapePath.RoundCap\n\n            PathCubic {\n                id: cubic\n                property real ctrlPtDist: 30\n                x: root.isForLoop ? (root.startX + root.endX) / 2  - loopArea.width / 2 : root.endX\n                y: root.isForLoop ? (root.startY + root.endY) / 2 : root.endY\n                relativeControl1X: ctrlPtDist\n                relativeControl1Y: 0\n                control2X: x - ctrlPtDist\n                control2Y: y\n            }\n        }\n\n        ShapePath {\n            id: pathSecondary\n            startX: (root.startX + root.endX) / 2 + loopArea.width / 2\n            startY: (root.startY + root.endY) / 2\n            fillColor: \"transparent\"\n\n            strokeColor: root.isForLoop ? root.color : \"transparent\"\n            strokeStyle: edge !== undefined && ((edge.src !== undefined && edge.src.isOutput) || edge.dst === undefined) ? ShapePath.SolidLine : ShapePath.DashLine\n            strokeWidth: root.thickness\n            // Final visual width of this path (never below 1)\n            readonly property real visualWidth: Math.max(strokeWidth, 1)\n            dashPattern: [6 / visualWidth, 4 / visualWidth]\n            capStyle: ShapePath.RoundCap\n\n            PathCubic {\n                id: cubicSecondary\n                property real ctrlPtDist: 30\n                x: root.endX\n                y: root.endY\n                relativeControl1X: ctrlPtDist\n                relativeControl1Y: 0\n                control2X: x - ctrlPtDist\n                control2Y: y\n            }\n        }\n    }\n\n    Item {\n        // Place the label at the middle of the edge\n        x: (root.startX + root.endX) / 2\n        y: (root.startY + root.endY) / 2\n        visible: root.isForLoop\n\n        Rectangle {\n            anchors.centerIn: parent\n            property int margin: 2\n            width: icon.width + 2 * margin\n            height: icon.height + 2 * margin\n            radius: width\n            color: path.strokeColor\n\n            MaterialToolLabel {\n                id: icon\n                anchors.centerIn: parent\n\n                iconText: MaterialIcons.loop\n                label.text: (root.iteration + 1) + \"/\" + root.loopSize + \" \"\n\n                labelIconColor: palette.base\n                ToolTip.text: \"Foreach Loop\"\n            }\n\n            MouseArea {\n                id: loopArea\n                anchors.fill: parent\n                hoverEnabled: true\n                onClicked: root.pressed(arguments[0])\n            }\n        }\n    }\n\n    EdgeMouseArea {\n        id: edgeArea\n        anchors.fill: parent\n        acceptedButtons: Qt.LeftButton | Qt.RightButton\n        thickness: root.thickness + 4\n        curveScale: cubic.ctrlPtDist / root.width  // Normalize by width\n        onPressed: function(event) {\n            root.pressed(event)\n        }\n        onReleased: function(event) {\n            root.released(event)\n        }\n\n    }\n}\n"
  },
  {
    "path": "meshroom/ui/qml/GraphEditor/GraphEditor.qml",
    "content": "import QtQuick\nimport QtQuick.Controls\nimport QtQuick.Layouts\n\nimport Controls 1.0\nimport MaterialIcons 2.2\nimport Utils 1.0\n\n/**\n * A component displaying a Graph (nodes, attributes and edges).\n */\n\nItem {\n    id: root\n\n    property variant uigraph: null  /// Meshroom UI graph (UIGraph)\n    readonly property variant graph: uigraph ? uigraph.graph : null  /// Core graph contained in the UI graph\n    property variant nodeTypesModel: null  /// The list of node types that can be instantiated\n    property real maxZoom: 2.0\n    property real minZoom: 0.1\n\n    property var edgeAboutToBeRemoved: undefined\n\n    property var _attributeToDelegate: ({})\n\n    // Signals\n    signal workspaceMoved()\n    signal workspaceClicked()\n\n    signal nodeDoubleClicked(var mouse, var node)\n    signal computeRequest(var nodes)\n    signal submitRequest(var nodes)\n\n    property int nbMeshroomScenes: 0\n    property int nbDraggedFiles: 0\n    signal filesDropped(var drop, var mousePosition)  // Files have been dropped\n\n    // Trigger initial fit() after initialization\n    // (ensure GraphEditor has its final size)\n    Component.onCompleted: firstFitTimer.start()\n\n    Timer {\n        id: firstFitTimer\n        running: false\n        interval: 10\n        onTriggered: fit()\n    }\n\n    clip: true\n\n    SystemPalette { id: activePalette }\n\n    /// Get node delegate for the given node object\n    function nodeDelegate(node) {\n        for(var i = 0; i < nodeRepeater.count; ++i) {\n            if (nodeRepeater.getItemAt(i).node === node)\n                return nodeRepeater.getItemAt(i)\n        }\n        return undefined\n    }\n\n    /// Duplicate a node and optionally all the following ones\n    function duplicateNode(duplicateFollowingNodes) {\n        var nodes\n        if (duplicateFollowingNodes) {\n            nodes = uigraph.duplicateNodesFrom(uigraph.getSelectedNodes())\n        } else {\n            nodes = uigraph.duplicateNodes(uigraph.getSelectedNodes())\n        }\n        uigraph.selectedNode = nodes[0]\n        uigraph.selectNodes(nodes)\n    }\n\n    /// Copy node content to clipboard\n    function copyNodes() {\n        var nodeContent = uigraph.getSelectedNodesContent()\n        if (nodeContent !== '') {\n            Clipboard.clear()\n            Clipboard.setText(nodeContent)\n        }\n    }\n\n    /// Paste content of clipboard to graph editor and create new node if valid\n    function pasteNodes() {\n        let finalPosition = undefined\n        if (mouseArea.containsMouse) {\n            finalPosition = mapToItem(draggable, mouseArea.mouseX, mouseArea.mouseY)\n        } else {\n            finalPosition = getCenterPosition()\n        }\n\n        const copiedContent = Clipboard.getText()\n        const nodes = uigraph.pasteNodes(copiedContent, finalPosition)\n        if (nodes.length > 0) {\n            uigraph.selectedNode = nodes[0]\n            uigraph.selectNodes(nodes)\n        }\n    }\n\n    /// Get the coordinates of the point at the center of the GraphEditor\n    function getCenterPosition() {\n        return mapToItem(draggable, mouseArea.width / 2, mouseArea.height / 2)\n    }\n\n    Keys.onPressed: function(event) {\n        if (event.key === Qt.Key_F) {\n            fit()\n        } else if (event.key === Qt.Key_Delete) {\n            if (event.modifiers === Qt.AltModifier) {\n                uigraph.removeNodesFrom(uigraph.getSelectedNodes())\n            } else {\n                uigraph.removeSelectedNodes()\n            }\n        } else if (event.key === Qt.Key_D) {\n            duplicateNode(event.modifiers === Qt.AltModifier)\n        } else if (event.key === Qt.Key_X) {\n            if (event.modifiers === Qt.ControlModifier) {\n                copyNodes()\n                uigraph.removeSelectedNodes()\n            }\n            else {\n                uigraph.disconnectSelectedNodes()\n            }\n        } else if (event.key === Qt.Key_C) {\n            if (event.modifiers === Qt.ControlModifier) {\n                copyNodes()\n            }\n            else {\n                colorSelector.toggle()\n            }\n        } else if (event.key === Qt.Key_V && event.modifiers === Qt.ControlModifier) {\n            pasteNodes()\n        } else if (event.key === Qt.Key_V && event.modifiers === Qt.ShiftModifier) {\n            uigraph.alignVertically()\n        } else if (event.key === Qt.Key_H && event.modifiers === Qt.ShiftModifier) {\n            uigraph.alignHorizontally()\n        } else if (event.key === Qt.Key_Tab) {\n            event.accepted = true\n            if (mouseArea.containsMouse) {\n                newNodeMenu.spawnPosition = mouseArea.mapToItem(draggable, mouseArea.mouseX, mouseArea.mouseY)\n                newNodeMenu.popup()\n            }\n        }\n    }\n\n    MouseArea {\n        id: mouseArea\n        anchors.fill: parent\n        property double factor: 1.15\n        property bool removingEdges: false\n        // Activate multisampling for edges antialiasing\n        layer.enabled: true\n        layer.samples: 8\n\n        hoverEnabled: true\n        acceptedButtons: Qt.LeftButton | Qt.RightButton | Qt.MiddleButton\n        drag.threshold: 0\n        drag.smoothed: false\n        cursorShape: drag.target == draggable ? Qt.ClosedHandCursor : removingEdges ? Qt.CrossCursor : Qt.ArrowCursor\n\n        onWheel: function(wheel) {\n            var zoomFactor = wheel.angleDelta.y > 0 ? factor : 1 / factor\n            var scale = draggable.scale * zoomFactor\n            scale = Math.min(Math.max(minZoom, scale), maxZoom)\n            if (draggable.scale == scale)\n                return\n            var point = mapToItem(draggable, wheel.x, wheel.y)\n            draggable.x += (1 - zoomFactor) * point.x * draggable.scale\n            draggable.y += (1 - zoomFactor) * point.y * draggable.scale\n            draggable.scale = scale\n            workspaceMoved()\n        }\n\n        onPressed: function(mouse) {\n            if (mouse.button != Qt.MiddleButton && mouse.modifiers == Qt.NoModifier) {\n                uigraph.clearNodeSelection()\n            }\n            if (mouse.button == Qt.LeftButton && (mouse.modifiers == Qt.NoModifier || mouse.modifiers & (Qt.ControlModifier | Qt.ShiftModifier))) {\n                nodeSelectionBox.startSelection(mouse)\n            }\n            if (mouse.button == Qt.MiddleButton || (mouse.button == Qt.LeftButton && mouse.modifiers & Qt.AltModifier)) {\n                drag.target = draggable // start drag\n            }\n            if (mouse.button == Qt.LeftButton && (mouse.modifiers & Qt.ControlModifier) && (mouse.modifiers & Qt.AltModifier)) {\n                edgeSelectionLine.startSelection(mouse)\n                removingEdges = true\n            }\n        }\n\n        onReleased: {\n            removingEdges = false\n            edgeSelectionLine.endSelection()\n            nodeSelectionBox.endSelection()\n            drag.target = null\n            root.forceActiveFocus()\n            workspaceClicked()\n        }\n        \n        onPositionChanged: {\n            if (drag.active)\n                workspaceMoved()\n        }\n\n        onClicked: function(mouse) {\n            if (mouse.button == Qt.RightButton) {\n                // Store mouse click position in 'draggable' coordinates as new node spawn position\n                newNodeMenu.spawnPosition = mouseArea.mapToItem(draggable, mouse.x, mouse.y)\n                newNodeMenu.popup()\n            }\n        }\n\n        // Contextual Menu for creating new nodes\n        // TODO: add filtering + validate on 'Enter'\n        Menu {\n            id: newNodeMenu\n            property point spawnPosition\n            property variant menuKeys: Object.keys(root.nodeTypesModel).concat(Object.values(MeshroomApp.pipelineTemplateNames))\n            height: searchBar.height + nodeMenuRepeater.height + instantiator.height\n\n            function createNode(nodeType) {\n                // \"nodeType\" might be a pipeline (artificially added in the \"Pipelines\" category) instead of a node\n                // If it is not a pipeline to import, then it must be a node\n                if (!importPipeline(nodeType)) {\n                    // Add node via the proper command in uigraph\n                    var node = uigraph.addNewNode(nodeType, spawnPosition)\n                    uigraph.selectedNode = node\n                    uigraph.selectNodes([node])\n                }\n                close()\n            }\n\n            function importPipeline(pipeline) {\n                if (MeshroomApp.pipelineTemplateNames.includes(pipeline)) {\n                    var url = MeshroomApp.pipelineTemplateFiles[MeshroomApp.pipelineTemplateNames.indexOf(pipeline)][\"path\"]\n                    var nodes = uigraph.importProject(Filepath.stringToUrl(url), spawnPosition)\n                    uigraph.selectedNode = nodes[0]\n                    uigraph.selectNodes(nodes)\n                    return true\n                }\n                return false\n            }\n\n            function parseCategories() {\n                // Organize nodes based on their category\n                // {\"category1\": [\"node1\", \"node2\"], \"category2\": [\"node3\", \"node4\"]}\n                let categories = {}\n                for (const [name, data] of Object.entries(root.nodeTypesModel)) {\n                    let category = data[\"category\"]\n                    if (categories[category] === undefined) {\n                        categories[category] = []\n                    }\n                    categories[category].push(name)\n                }\n\n                // Add a \"Pipelines\" category, filled with the list of templates to create pipelines from the menu\n                if (MeshroomApp.pipelineTemplateNames.length > 0) {\n                    categories[\"Pipelines\"] = MeshroomApp.pipelineTemplateNames\n                }\n\n                return categories\n            }\n\n            onVisibleChanged: {\n                searchBar.clear()\n                if (visible) {\n                    // When menu is shown, give focus to the TextField filter\n                    searchBar.forceActiveFocus()\n                }\n            }\n\n            SearchBar {\n                id: searchBar\n                width: parent.width\n            }\n\n            // menuItemDelegate is wrapped in a component so it can be used in both the search bar and sub-menus\n            Component {\n                id: menuItemDelegateComponent\n                MenuItem {\n                    id: menuItemDelegate\n                    font.pointSize: 8\n                    padding: 3\n\n                    // Hide items that does not match the filter text\n                    visible: modelData.toLowerCase().indexOf(searchBar.text.toLowerCase()) > -1\n                    text: modelData\n                    // Forward key events to the search bar to continue typing seamlessly\n                    // even if this delegate took the activeFocus due to mouse hovering\n                    Keys.forwardTo: [searchBar.textField]\n                    Keys.onPressed: function(event) {\n                        event.accepted = false\n                        switch (event.key) {\n                            case Qt.Key_Return:\n                            case Qt.Key_Enter:\n                                // Create node on validation (Enter/Return keys)\n                                newNodeMenu.createNode(modelData)\n                                event.accepted = true\n                                break\n                            case Qt.Key_Up:\n                            case Qt.Key_Down:\n                            case Qt.Key_Left:\n                            case Qt.Key_Right:\n                                break  // Ignore if arrow key was pressed to let the menu be controlled\n                            default:\n                                searchBar.forceActiveFocus()\n                        }\n                    }\n                    // Set the priority ordering of the keys to be Item's own Key Handling > ForwardTo\n                    Keys.priority: Keys.AfterItem\n                    // Create node on mouse click\n                    onClicked: newNodeMenu.createNode(modelData)\n\n                    states: [\n                        State {\n                            // Additional property setting when the MenuItem is not visible\n                            when: !visible\n                            name: \"invisible\"\n                            PropertyChanges {\n                                target: menuItemDelegate\n                                height: 0  // Make sure the item is no visible by setting height to 0\n                                focusPolicy: Qt.NoFocus  // Don't grab focus when not visible\n                            }\n                        }\n                    ]\n                }\n            }\n\n            Repeater {\n                id: nodeMenuRepeater\n                model: searchBar.text !== \"\" ? Object.values(newNodeMenu.menuKeys) : undefined\n\n                // Create Menu items from available items\n                delegate: menuItemDelegateComponent\n            }\n\n            // Dynamically add the menu categories\n            Instantiator {\n                id: instantiator\n                model: (searchBar.text === \"\") ? Object.keys(newNodeMenu.parseCategories()).sort() : undefined\n                onObjectAdded: function(index, object) {\n                    // Add sub-menu under the search bar\n                    newNodeMenu.insertMenu(index + 1, object)\n                }\n                onObjectRemoved: function(index, object) {\n                    newNodeMenu.removeMenu(object)\n                }\n\n                delegate: Menu {\n                    title: modelData\n                    id: newNodeSubMenu\n\n                    Instantiator {\n                        model: newNodeMenu.visible ? newNodeMenu.parseCategories()[modelData] : undefined\n                        onObjectAdded: function(index, object) {\n                            newNodeSubMenu.insertItem(index, object)\n                        }\n                        onObjectRemoved: function(index, object) {\n                            newNodeSubMenu.removeItem(object)\n                        }\n                        delegate: menuItemDelegateComponent\n                    }\n                }\n            }\n        }\n\n        // Informative contextual menu when graph is read-only\n        Menu {\n            id: lockedMenu\n            MenuItem {\n                id: item\n                font.pointSize: 8\n                enabled: false\n                text: \"Computing - Graph is Locked!\"\n            }\n        }\n\n        Item {\n            id: draggable\n            transformOrigin: Item.TopLeft\n            width: 1000\n            height: 1000\n\n            Popup {\n                id: edgeMenu\n                property var currentEdge: null\n                property bool forLoop: false\n\n                onOpened: {\n                    expandButton.canExpand = uigraph.canExpandForLoop(edgeMenu.currentEdge)\n                }\n\n                contentItem: Row {\n                    IntSelector {\n                        id: loopIterationSelector\n                        tooltipText: \"Iterations\"\n                        visible: edgeMenu.currentEdge && edgeMenu.forLoop\n\n                        enabled: expandButton.canExpand\n\n                        property var listAttr: edgeMenu.currentEdge ? edgeMenu.currentEdge.src.root : null\n\n                        Connections {\n                            target: edgeMenu\n                            function onCurrentEdgeChanged() {\n                                if (edgeMenu.currentEdge) {\n                                    loopIterationSelector.listAttr = edgeMenu.currentEdge.src.root\n                                    loopIterationSelector.value = loopIterationSelector.listAttr ? loopIterationSelector.listAttr.value.indexOf(edgeMenu.currentEdge.src) + 1 : 0\n                                }\n                            }\n                        }\n\n                        // We add 1 to the index because of human readable index (starting at 1) \n                        value: listAttr ? listAttr.value.indexOf(edgeMenu.currentEdge.src) + 1 : 0\n                        range: { \"min\": 1, \"max\": listAttr ? listAttr.value.count : 0 }\n\n                        onValueChanged: {\n                            if (listAttr === null) {\n                                return\n                            }\n                            const newSrcAttr = listAttr.value.at(value - 1)\n                            const dst = edgeMenu.currentEdge.dst\n\n                            // If the edge exists, do not replace it\n                            if (newSrcAttr === edgeMenu.currentEdge.src && dst === edgeMenu.currentEdge.dst) {\n                                return\n                            }\n                            edgeMenu.currentEdge = uigraph.replaceEdge(edgeMenu.currentEdge, newSrcAttr, dst)\n                        }\n                    }\n\n                    MaterialToolButton {\n                        font.pointSize: 13\n                        ToolTip.text: \"Remove Edge\"\n                        enabled: edgeMenu.currentEdge && !edgeMenu.currentEdge.dst.node.locked && !edgeMenu.currentEdge.dst.isReadOnly\n                        text: MaterialIcons.delete_\n                        onClicked: {\n                            uigraph.removeEdge(edgeMenu.currentEdge)\n                            edgeMenu.close()\n                        }\n                    }\n\n                    MaterialToolButton {\n                        id: expandButton\n\n                        property bool canExpand: edgeMenu.currentEdge && edgeMenu.forLoop\n\n                        visible: edgeMenu.currentEdge && edgeMenu.forLoop && canExpand\n                        enabled: edgeMenu.currentEdge && !edgeMenu.currentEdge.dst.node.locked && !edgeMenu.currentEdge.dst.isReadOnly\n                        font.pointSize: 13\n                        ToolTip.text: \"Expand\"\n                        text: MaterialIcons.open_in_full\n\n                        onClicked: {\n                            edgeMenu.currentEdge = uigraph.expandForLoop(edgeMenu.currentEdge)\n                            canExpand = false\n                            edgeMenu.close()\n                        }\n                    }\n\n                    MaterialToolButton {\n                        id: collapseButton\n\n                        visible: edgeMenu.currentEdge && edgeMenu.forLoop && !expandButton.canExpand\n                        enabled: edgeMenu.currentEdge && !edgeMenu.currentEdge.dst.node.locked && !edgeMenu.currentEdge.dst.isReadOnly\n                        font.pointSize: 13\n                        ToolTip.text: \"Collapse\"\n                        text: MaterialIcons.close_fullscreen\n\n                        onClicked: {\n                            uigraph.collapseForLoop(edgeMenu.currentEdge)\n                            expandButton.canExpand = true\n                            edgeMenu.close()\n                        }\n                    }\n                }\n            }\n\n            // Edges\n            Repeater {\n                id: edgesRepeater\n\n                // Delay edges loading after nodes (edges needs attribute pins to be created)\n                model: nodeRepeater.loaded && root.graph ? root.graph.edges : undefined\n\n                delegate: Edge {\n                    function getAttributePin(attribute) {\n                        // Get the first visible parent of \"attribute\"\n                        let dstAttributeDelegate = root._attributeToDelegate[attribute]\n                        if (dstAttributeDelegate && dstAttributeDelegate.visible) {\n                            return dstAttributeDelegate\n                        }\n\n                        if (!attribute || !attribute.root) {\n                            return null\n                        }\n\n                        let index = Array.from(attribute.root.value).indexOf(attribute)\n                        let groupAttributeDelegate = null\n                        let groupAttribute = attribute\n\n                        while (groupAttribute && (!groupAttributeDelegate ||\n                               (groupAttributeDelegate && !groupAttributeDelegate.visible && groupAttribute && groupAttribute.root))) {\n                            groupAttribute = groupAttribute ? groupAttribute.root : null\n\n                            if (groupAttribute) {\n                                groupAttributeDelegate = root._attributeToDelegate[groupAttribute]\n                            }\n                        }\n\n                        if (groupAttributeDelegate) {\n                            return groupAttributeDelegate\n                        }\n\n                        return dstAttributeDelegate\n                    }\n\n                    property var src: getAttributePin(edge.src)\n                    property var dst: getAttributePin(edge.dst)\n                    property bool isValidEdge: src !== null && dst !== null\n                    visible: isValidEdge && src.visible && dst.visible\n\n                    property bool forLoop: {\n                        if (src !== null && dst !== null) {\n                            return src.attribute.type === \"ListAttribute\" && dst.attribute.type != \"ListAttribute\"\n                        }\n                        return false\n                    }\n\n                    property bool inFocus: containsMouse || (edgeMenu.opened && edgeMenu.currentEdge === edge)\n\n                    edge: object\n                    isForLoop: forLoop\n                    loopSize: forLoop ? edge.src.root.value.count : 0\n                    iteration: forLoop ? edge.src.root.value.indexOf(edge.src) : 0\n                    color: edge.dst === root.edgeAboutToBeRemoved ? \"red\" : inFocus ? activePalette.highlight : activePalette.text\n                    thickness: {\n                        if (forLoop) {\n                            return (inFocus) ? 4 : 3\n                        }\n                        return (inFocus) ? 2 : 1\n                    }\n                    point1x: isValidEdge ? src.globalX + src.outputAnchorPos.x : 0\n                    point1y: isValidEdge ? src.globalY + src.outputAnchorPos.y : 0\n                    point2x: isValidEdge ? dst.globalX + dst.inputAnchorPos.x : 0\n                    point2y: isValidEdge ? dst.globalY + dst.inputAnchorPos.y : 0\n                    onPressed: function(event) {\n                        const canEdit = !edge.dst.node.locked\n\n                        if (event.button) {\n                            if (canEdit && (event.modifiers & Qt.AltModifier)) {\n                                uigraph.removeEdge(edge)\n                            } else if (event.button == Qt.RightButton) {\n                                edgeMenu.currentEdge = edge\n                                edgeMenu.forLoop = forLoop\n                                var spawnPosition = mouseArea.mapToItem(draggable, mouseArea.mouseX, mouseArea.mouseY)\n                                edgeMenu.x = spawnPosition.x\n                                edgeMenu.y = spawnPosition.y\n                                edgeMenu.open()\n                            }\n                        }\n                    }\n                }\n            }\n\n            Loader {\n                id: nodeMenuLoader\n                property var currentNode: null\n                active: currentNode != null\n                sourceComponent: nodeMenuComponent\n\n                function load(node) {\n                    currentNode = node\n                }\n\n                function unload() {\n                    currentNode = null\n                }\n\n                function showDataDeletionDialog(deleteFollowing: bool, callback) {\n                    uigraph.forceNodesStatusUpdate()\n                    const dialog = deleteDataDialog.createObject(\n                        root,\n                        {\n                            \"node\": currentNode,\n                            \"deleteFollowing\": deleteFollowing\n                        }\n                    )\n                    dialog.open()\n                    if(callback)\n                        dialog.dataDeleted.connect(callback)\n                }\n            }\n\n            Component {\n                id: nodeMenuComponent\n                Menu {\n                    id: nodeMenu\n                    \n                    property var currentNode: nodeMenuLoader.currentNode\n\n                    // Cache computatibility/submitability status of each selected node.\n                    readonly property var nodeSubmitOrComputeStatus: {\n                        var collectedStatus = ({})\n                        uigraph.nodeSelection.selectedIndexes.forEach(function(idx) {\n                            const node = uigraph.graph.nodes.at(idx.row)\n                            collectedStatus[node] = uigraph.graph.canSubmitOrCompute(node)\n                        })\n                        return collectedStatus\n                    }\n\n                    readonly property bool isSelectionFullyComputed: {\n                        return uigraph.nodeSelection.selectedIndexes.every(function(idx) {\n                            const node = uigraph.graph.nodes.at(idx.row)\n                            return node.isComputed\n                        })\n                    }\n\n                    // Selection contains only compatibility nodes\n                    readonly property bool isSelectionFullyCompatibility: {\n                        return uigraph.nodeSelection.selectedIndexes.every(function(idx) {\n                            const node = uigraph.graph.nodes.at(idx.row)\n                            return node.isCompatibilityNode\n                        })\n                    }\n\n                    // Selection contains at least one computable node type\n                    readonly property bool selectionContainsComputableNodeType: {\n                        return uigraph.nodeSelection.selectedIndexes.some(function(idx) {\n                            const node = uigraph.graph.nodes.at(idx.row)\n                            return node.isComputableType\n                        })\n                    }\n\n                    readonly property bool canSelectionBeComputed: {\n                        if(!selectionContainsComputableNodeType)\n                            return false\n                        if(isSelectionFullyCompatibility)\n                            return false\n                        if(isSelectionFullyComputed)\n                            return true\n                        var b = uigraph.nodeSelection.selectedIndexes.every(function(idx) {\n                            const node = uigraph.graph.nodes.at(idx.row)\n                            return (\n                                node.isComputed ||\n                                (uigraph.graph.canComputeTopologically(node) &&\n                                // canCompute if canSubmitOrCompute == 1(can compute) or 3(can compute & submit)\n                                nodeSubmitOrComputeStatus[node] % 2 == 1)\n                            )\n                        })\n                        return b\n                    }\n\n                    readonly property bool isSelectionSubmitable: uigraph.canSubmit && selectionContainsComputableNodeType\n\n                    readonly property bool canSelectionBeSubmitted: {\n                        if(!selectionContainsComputableNodeType)\n                            return false\n                        if(isSelectionFullyCompatibility)\n                            return false\n                        if(isSelectionFullyComputed)\n                            return true\n                        return uigraph.nodeSelection.selectedIndexes.every(function(idx) {\n                            const node = uigraph.graph.nodes.at(idx.row)\n                            return (\n                                node.isComputed ||\n                                (uigraph.graph.canComputeTopologically(node) &&\n                                 // canSubmit if canSubmitOrCompute == 2(can submit) or 3(can compute & submit)\n                                 nodeSubmitOrComputeStatus[node] > 1)\n                            )\n                        })\n                    }\n\n                    width: 220\n\n                    Component.onCompleted: popup()\n                    onClosed: nodeMenuLoader.unload()\n\n                    MenuItem {\n                        id: computeMenuItem\n                        text: nodeMenu.isSelectionFullyComputed ? \"Re-Compute\" : \"Compute\"\n                        visible: nodeMenu.selectionContainsComputableNodeType\n                        height: visible ? implicitHeight : 0\n                        enabled: nodeMenu.canSelectionBeComputed\n\n                        onTriggered: {\n                            if (nodeMenu.isSelectionFullyComputed) {\n                                nodeMenuLoader.showDataDeletionDialog(\n                                    false, \n                                    function(request, uigraph) {\n                                        request(uigraph.getSelectedNodes())\n                                    }.bind(null, computeRequest, uigraph)\n                                )\n                            } else {\n                                computeRequest(uigraph.getSelectedNodes())\n                            }\n                        }\n                    }\n                    MenuItem {\n                        id: submitMenuItem\n\n                        text: nodeMenu.isSelectionFullyComputed ? \"Re-Submit\" : \"Submit\"\n                        visible: nodeMenu.isSelectionSubmitable\n                        height: visible ? implicitHeight : 0\n                        enabled: nodeMenu.canSelectionBeSubmitted\n\n                        onTriggered: {\n                            if (nodeMenu.isSelectionFullyComputed) {\n                                nodeMenuLoader.showDataDeletionDialog(\n                                    false, \n                                    function(request, uigraph) {\n                                        request(uigraph.getSelectedNodes())\n                                    }.bind(null, submitRequest, uigraph)\n                                )\n                            } else {\n                                submitRequest(uigraph.getSelectedNodes())\n                            }\n                        }\n                    }\n                    MenuItem {\n                        text: \"Stop Computation\"\n                        enabled: nodeMenu.currentNode.canBeStopped() && nodeMenu.currentNode.globalExecMode == \"LOCAL\"\n                        visible: enabled\n                        height: visible ? implicitHeight : 0\n                        onTriggered: uigraph.stopNodeComputation(nodeMenu.currentNode)\n                    }\n                    MenuItem {\n                        text: \"Cancel Computation\"\n                        enabled: nodeMenu.currentNode.canBeCanceled() && nodeMenu.currentNode.globalExecMode == \"LOCAL\"\n                        visible: enabled\n                        height: visible ? implicitHeight : 0\n                        onTriggered: uigraph.cancelNodeComputation(nodeMenu.currentNode)\n                    }\n                    MenuItem {\n                        text: \"Interrupt Job\"\n                        enabled: nodeMenu.currentNode.canBeStopped() && nodeMenu.currentNode.globalExecMode == \"EXTERN\"\n                        visible: enabled\n                        height: visible ? implicitHeight : 0\n                        onTriggered: uigraph.stopNode(nodeMenu.currentNode)\n                    }\n                    MenuItem {\n                        text: \"Cancel Job\"\n                        enabled: nodeMenu.currentNode.canBeCanceled() && nodeMenu.currentNode.globalExecMode == \"EXTERN\"\n                        visible: enabled\n                        height: visible ? implicitHeight : 0\n                        onTriggered: uigraph.stopNode(nodeMenu.currentNode)\n                    }\n                    MenuItem {\n                        text: \"Retry Error Tasks\"\n                        enabled: nodeMenu.currentNode.globalExecMode == \"EXTERN\" && [\"ERROR\", \"STOPPED\", \"KILLED\"].includes(nodeMenu.currentNode.globalStatus)\n                        visible: enabled\n                        height: visible ? implicitHeight : 0\n                        onTriggered: uigraph.restartJobErrorTasks(nodeMenu.currentNode)\n                    }\n                    MenuItem {\n                        text: \"Open Folder\"\n                        visible: nodeMenu.currentNode.isComputableType\n                        height: visible ? implicitHeight : 0\n                        onTriggered: Qt.openUrlExternally(Filepath.stringToUrl(nodeMenu.currentNode.internalFolder))\n                    }\n                    MenuSeparator {\n                        visible: nodeMenu.currentNode.isComputableType\n                    }\n                    MenuItem {\n                        text: \"Cut Node(s)\"\n                        enabled: true\n                        ToolTip.text: \"Copy selection to the clipboard and remove it\"\n                        ToolTip.visible: hovered\n                        onTriggered: {\n                            copyNodes()\n                            uigraph.removeSelectedNodes()\n                        }\n                    }\n                    MenuItem {\n                        text: \"Copy Node(s)\"\n                        enabled: true\n                        ToolTip.text: \"Copy selection to the clipboard\"\n                        ToolTip.visible: hovered\n                        onTriggered: copyNodes()\n                    }\n                    MenuItem {\n                        text: \"Paste Node(s)\"\n                        enabled: true\n                        ToolTip.text: \"Copy selection to the clipboard and immediately paste it\"\n                        ToolTip.visible: hovered\n                        onTriggered: {\n                            copyNodes()\n                            pasteNodes()\n                        }\n                    }\n                    MenuItem {\n                        text: \"Disconnect Node(s)\"\n                        enabled: true\n                        ToolTip.text: \"Disconnect all edges from the selected Node(s)\"\n                        ToolTip.visible: hovered\n                        onTriggered: uigraph.disconnectSelectedNodes()\n                    }\n                    MenuItem {\n                        text: \"Duplicate Node(s)\" + (duplicateFollowingButton.hovered ? \" From Here\" : \"\")\n                        enabled: true\n                        onTriggered: duplicateNode(false)\n                        MaterialToolButton {\n                            id: duplicateFollowingButton\n                            height: parent.height\n                            anchors {\n                                right: parent.right\n                                rightMargin: parent.padding\n                            }\n                            text: MaterialIcons.fast_forward\n                            onClicked: {\n                                duplicateNode(true)\n                                nodeMenu.close()\n                            }\n                        }\n                    }\n                    MenuItem {\n                        text: \"Remove Node(s)\" + (removeFollowingButton.hovered ? \" From Here\" : \"\")\n                        enabled: !nodeMenu.currentNode.locked\n                        onTriggered: uigraph.removeSelectedNodes()\n                        MaterialToolButton {\n                            id: removeFollowingButton\n                            height: parent.height\n                            anchors {\n                                right: parent.right\n                                rightMargin: parent.padding\n                            }\n                            text: MaterialIcons.fast_forward\n                            onClicked: {\n                                uigraph.removeNodesFrom(uigraph.getSelectedNodes())\n                                nodeMenu.close()\n                            }\n                        }\n                    }\n                    MenuSeparator {\n                        visible: nodeMenu.currentNode.isComputableType\n                    }\n                    MenuItem {\n                        id: deleteDataMenuItem\n                        text: \"Delete Data\" + (deleteFollowingButton.hovered ? \" From Here\" : \"\" ) + \"...\"\n                        visible: nodeMenu.currentNode.isComputableType\n                        height: visible ? implicitHeight : 0\n                        enabled: {\n                            if (!nodeMenu.currentNode)\n                                return false\n                            // Check if the current node is locked (needed because it does not belong to its own duplicates list)\n                            if (nodeMenu.currentNode.locked)\n                                return false\n                            // Check if at least one of the duplicate nodes is locked\n                            for (let i = 0; i < nodeMenu.currentNode.duplicates.count; ++i) {\n                                if (nodeMenu.currentNode.duplicates.at(i).locked)\n                                    return false\n                            }\n                            return true\n                        }\n\n                        onTriggered: nodeMenuLoader.showDataDeletionDialog(false)\n\n                        MaterialToolButton {\n                            id: deleteFollowingButton\n                            anchors {\n                                right: parent.right\n                                rightMargin: parent.padding\n                            }\n                            height: parent.height\n                            text: MaterialIcons.fast_forward\n                            onClicked: {\n                                nodeMenuLoader.showDataDeletionDialog(true)\n                                nodeMenu.close()\n                            }\n                        }\n                    }\n                }\n            }\n\n            // Confirmation dialog for node cache deletion\n            Component {\n                id: deleteDataDialog\n                MessageDialog  {\n                    property var node\n                    property bool deleteFollowing: false\n\n                    signal dataDeleted()\n\n                    focus: true\n                    modal: false\n                    header.visible: false\n\n                    text: \"Delete Data of '\" + node.label + \"'\" + (uigraph.nodeSelection.selectedIndexes.length > 1 ? \" and other selected Nodes\" : \"\") + (deleteFollowing ?  \" and following Nodes?\" : \"?\")\n                    helperText: \"Warning: This operation cannot be undone.\"\n                    standardButtons: Dialog.Yes | Dialog.Cancel\n\n                    onAccepted: {\n                        if (deleteFollowing)\n                            uigraph.clearDataFrom(uigraph.getSelectedNodes())\n                        else\n                            uigraph.clearSelectedNodesData()\n                        dataDeleted()\n                    }\n                    onClosed: destroy()\n                }\n            }\n\n            // Nodes\n            Repeater {\n                id: nodeRepeater\n\n                model: root.graph ? root.graph.nodes : undefined\n\n                property bool loaded: model ? count === model.count : false\n                property bool ongoingDrag: false\n                property bool updateSelectionOnClick: false\n                property var temporaryEdgeAboutToBeRemoved: undefined\n\n                function getItemAt(index) {\n                    const loader = itemAt(index)\n                    if (loader && loader.item)\n                        return loader.item\n                    return null\n                }\n\n                delegate: Loader {\n                    id: nodeLoader\n                    Component {\n                        id: nodeComponent\n                        Node {\n                            id: nodeDelegate\n\n                            node: object\n                            width: uigraph.layout.nodeWidth\n\n                            mainSelected: uigraph.selectedNode === node\n                            hovered: uigraph.hoveredNode === node\n\n                            // ItemSelectionModel.hasSelection triggers updates anytime the selectionChanged() signal is emitted.\n                            selected: uigraph.nodeSelection.hasSelection ? uigraph.nodeSelection.isRowSelected(index) : false\n\n                            onAttributePinCreated: function(attribute, pin) { registerAttributePin(attribute, pin) }\n                            onAttributePinDeleted: function(attribute, pin) { unregisterAttributePin(attribute, pin) }\n\n                            onShaked: {\n                                uigraph.disconnectSelectedNodes()\n                            }\n\n                            onPressed: function(mouse) {\n                                nodeRepeater.updateSelectionOnClick = true\n                                nodeRepeater.ongoingDrag = true\n\n                                let selectionMode = ItemSelectionModel.NoUpdate\n\n                                if (!selected) {\n                                    selectionMode = ItemSelectionModel.ClearAndSelect\n                                }\n\n                                if (mouse.button === Qt.LeftButton) {\n                                    if (mouse.modifiers & Qt.ShiftModifier) {\n                                        selectionMode = ItemSelectionModel.Select\n                                    }\n                                    if (mouse.modifiers & Qt.ControlModifier) {\n                                        selectionMode = ItemSelectionModel.Toggle\n                                    }\n                                    if (mouse.modifiers & Qt.AltModifier) {\n                                        let selectFollowingMode = ItemSelectionModel.ClearAndSelect\n                                        if (mouse.modifiers & Qt.ShiftModifier) {\n                                            selectFollowingMode = ItemSelectionModel.Select\n                                        }\n                                        uigraph.selectFollowing(node, selectFollowingMode)\n                                        // Indicate selection has been dealt with by setting conservative Select mode.\n                                        selectionMode = ItemSelectionModel.Select\n                                    }\n                                }\n                                else if (mouse.button === Qt.RightButton) {\n                                    if (selected) {\n                                        // Keep the full selection when right-clicking on an already selected node.\n                                        nodeRepeater.updateSelectionOnClick = false\n                                    }\n                                }\n\n                                if (selectionMode != ItemSelectionModel.NoUpdate) {\n                                    nodeRepeater.updateSelectionOnClick = false\n                                    uigraph.selectNodeByIndex(index, selectionMode)\n                                }\n\n                                // If the node is selected after this, make it the active selected node.\n                                if (selected) {\n                                    uigraph.selectedNode = node\n                                }\n\n                                // Open the node context menu once selection has been updated.\n                                if (mouse.button == Qt.RightButton) {\n                                    nodeMenuLoader.load(node)\n                                }\n                            }\n\n                            onReleased: function(mouse, wasDragged) {\n                                nodeRepeater.ongoingDrag = false\n                            }\n\n                            // Only called when the node has not been dragged.\n                            onClicked: function(mouse) {\n                                if (!nodeRepeater.updateSelectionOnClick) {\n                                    return\n                                }\n                                uigraph.selectNodeByIndex(index)\n                            }\n\n                            onDoubleClicked: function(mouse) { root.nodeDoubleClicked(mouse, node) }\n\n                            onEntered: uigraph.hoveredNode = node\n                            onExited: uigraph.hoveredNode = null\n\n                            onEdgeAboutToBeRemoved: function(input) {\n                                /*\n                                 * Sometimes the signals are not in the right order because of weird Qt/QML update order\n                                 * (next DropArea entered signal before previous DropArea exited signal) so edgeAboutToBeRemoved\n                                 * must be set to undefined before it can be set to another attribute object.\n                                 */\n                                if (input === undefined) {\n                                    if (nodeRepeater.temporaryEdgeAboutToBeRemoved === undefined) {\n                                        root.edgeAboutToBeRemoved = input\n                                    } else {\n                                        root.edgeAboutToBeRemoved = nodeRepeater.temporaryEdgeAboutToBeRemoved\n                                        nodeRepeater.temporaryEdgeAboutToBeRemoved = undefined\n                                    }\n                                } else {\n                                    if (root.edgeAboutToBeRemoved === undefined) {\n                                        root.edgeAboutToBeRemoved = input\n                                    } else {\n                                        nodeRepeater.temporaryEdgeAboutToBeRemoved = input\n                                    }\n                                }\n                            }\n\n                            // Interactive dragging: move the visual delegates\n                            onPositionChanged: {\n                                if (!selected || !dragging) {\n                                    return\n                                }\n\n                                // Check for shake on the node\n                                checkForShake()\n\n                                // Compute offset between the delegate and the stored node position.\n                                const offset = Qt.point(x - node.x, y - node.y)\n\n                                uigraph.nodeSelection.selectedIndexes.forEach(function(idx) {\n                                    if (idx != index) {\n                                        const delegate = nodeRepeater.getItemAt(idx.row)\n                                        delegate.x = delegate.node.x + offset.x\n                                        delegate.y = delegate.node.y + offset.y\n                                    }\n                                })\n                            }\n\n                            // After drag: apply the final offset to all selected nodes\n                            onMoved: function(position) {\n                                const offset = Qt.point(position.x - node.x, position.y - node.y)\n                                uigraph.moveSelectedNodesBy(offset)\n                            }\n\n                            Behavior on x {\n                                enabled: !nodeRepeater.ongoingDrag\n                                NumberAnimation { duration: 100 }\n                            }\n                            Behavior on y {\n                                enabled: !nodeRepeater.ongoingDrag\n                                NumberAnimation { duration: 100 }\n                            }\n                        }\n                    }\n\n                    Component {\n                        id: backdropComponent\n                        Backdrop {\n                            id: backdropDelegate\n                            node: object\n                            modelInstantiator: nodeRepeater\n\n                            mainSelected: uigraph.selectedNode === node\n                            hovered: uigraph.hoveredNode === node\n\n                            // ItemSelectionModel.hasSelection triggers updates anytime the selectionChanged() signal is emitted.\n                            selected: uigraph.nodeSelection.hasSelection ? uigraph.nodeSelection.isRowSelected(index) : false\n\n                            onPressed: function(mouse) {\n                                nodeRepeater.updateSelectionOnClick = true\n                                nodeRepeater.ongoingDrag = true\n\n                                ctrlHeld = (mouse.modifiers & Qt.ControlModifier) !== 0\n\n                                let selectionMode = ItemSelectionModel.NoUpdate\n\n                                if (!selected) {\n                                    selectionMode = ItemSelectionModel.ClearAndSelect\n                                }\n\n                                if (mouse.button === Qt.LeftButton) {\n                                    if (mouse.modifiers & Qt.ShiftModifier) {\n                                        selectionMode = ItemSelectionModel.Select\n                                    }\n                                    if (mouse.modifiers & Qt.ControlModifier) {\n                                        selectionMode = ItemSelectionModel.Clear\n                                    }\n                                    if (mouse.modifiers & Qt.AltModifier) {\n                                        let selectFollowingMode = ItemSelectionModel.ClearAndSelect\n                                        if (mouse.modifiers & Qt.ShiftModifier) {\n                                            selectFollowingMode = ItemSelectionModel.Select\n                                        }\n                                        uigraph.selectFollowing(node, selectFollowingMode)\n                                        // Indicate selection has been dealt with by setting conservative Select mode.\n                                        selectionMode = ItemSelectionModel.Select\n                                    }\n                                } else if (mouse.button === Qt.RightButton) {\n                                    if (selected) {\n                                        // Keep the full selection when right-clicking on an already selected node.\n                                        nodeRepeater.updateSelectionOnClick = false\n                                    }\n                                }\n\n                                if (selectionMode != ItemSelectionModel.NoUpdate) {\n                                    nodeRepeater.updateSelectionOnClick = false\n                                    uigraph.selectNodeByIndex(index, selectionMode)\n                                }\n\n                                // If the node is selected after this, make it the active selected node.\n                                if (selected) {\n                                    uigraph.selectedNode = node\n                                }\n\n                                if (!(mouse.modifiers & Qt.AltModifier)) {\n                                    uigraph.selectNodesByIndices(childrenIndices, ItemSelectionModel.Select)\n                                }\n\n                                // Open the node context menu once selection has been updated.\n                                if (mouse.button == Qt.RightButton) {\n                                    nodeMenuLoader.load(node)\n                                }\n                            }\n\n                            onReleased: function(mouse, wasDragged) {\n                                ctrlHeld = false\n                                nodeRepeater.ongoingDrag = false\n                            }\n\n                            // Only called when the node has not been dragged.\n                            onClicked: function(mouse) {\n                                if (!nodeRepeater.updateSelectionOnClick) {\n                                    return\n                                }\n                                uigraph.selectNodeByIndex(index)\n\n                                if (!(mouse.modifiers & Qt.AltModifier)) {\n                                    uigraph.selectNodesByIndices(childrenIndices, ItemSelectionModel.Select)\n                                }\n                            }\n\n                            onDoubleClicked: function(mouse) { root.nodeDoubleClicked(mouse, node) }\n\n                            onResized: function(width, height) {\n                                uigraph.resizeNode(node, width, height)\n                            }\n                            onResizedAndMoved: function(width, height, position) {\n                                uigraph.resizeAndMoveNode(node, width, height, position)\n                            }\n\n                            onEntered: uigraph.hoveredNode = node\n                            onExited: uigraph.hoveredNode = null\n\n                            // Interactive dragging: move the visual delegates\n                            onPositionChanged: {\n                                if (!selected || !dragging) {\n                                    return\n                                }\n\n                                // Compute offset between the delegate and the stored node position.\n                                const offset = Qt.point(x - node.x, y - node.y)\n\n                                uigraph.nodeSelection.selectedIndexes.forEach(function(idx) {\n                                    if (idx != index) {\n                                        const delegate = nodeRepeater.getItemAt(idx.row)\n                                        delegate.x = delegate.node.x + offset.x\n                                        delegate.y = delegate.node.y + offset.y\n                                    }\n                                })\n                            }\n\n                            // After drag: apply the final offset to all selected nodes\n                            onMoved: function(position) {\n                                const offset = Qt.point(position.x - node.x, position.y - node.y)\n                                uigraph.moveSelectedNodesBy(offset)\n                            }\n\n                            Behavior on x {\n                                enabled: !nodeRepeater.ongoingDrag && !resizing && !uigraph.animationsDisabled\n                                NumberAnimation { duration: 100 }\n                            }\n                            Behavior on y {\n                                enabled: !nodeRepeater.ongoingDrag && !resizing && !uigraph.animationsDisabled\n                                NumberAnimation { duration: 100 }\n                            }\n                        }\n                    }\n\n                    sourceComponent: object.isBackdropNode ? backdropComponent : nodeComponent\n\n                    onLoaded: {\n                        nodeLoader.z = nodeLoader.item.z\n                    }\n                }\n            }\n        }\n\n        DelegateSelectionBox {\n            id: nodeSelectionBox\n            mouseArea: mouseArea\n            modelInstantiator: nodeRepeater\n            container: draggable\n            onDelegateSelectionEnded: function(selectedIndices, modifiers) {\n                let selectionMode = ItemSelectionModel.ClearAndSelect\n                if(modifiers & Qt.ShiftModifier) {\n                    selectionMode = ItemSelectionModel.Select\n                } else if(modifiers & Qt.ControlModifier) {\n                    selectionMode = ItemSelectionModel.Deselect\n                }\n                uigraph.selectNodesByIndices(selectedIndices, selectionMode)\n            }\n        }\n\n        DelegateSelectionLine {\n            id: edgeSelectionLine\n            mouseArea: mouseArea\n            modelInstantiator: edgesRepeater\n            container: draggable\n            onDelegateSelectionEnded: function(selectedIndices, modifiers) {\n                uigraph.deleteEdgesByIndices(selectedIndices)\n            }\n        }\n\n        DropArea {\n            id: dropArea\n            anchors.fill: parent\n            keys: [\"text/uri-list\"]\n            onEntered: function(drag) {\n                nbMeshroomScenes = 0\n                nbDraggedFiles = drag.urls.length\n\n                drag.urls.forEach(function(file) {\n                    if (String(file).endsWith(\".mg\")) {\n                        nbMeshroomScenes++\n                    }\n                })\n            }\n\n            onDropped: function(drop) {\n                if (nbMeshroomScenes == nbDraggedFiles || nbMeshroomScenes == 0) {\n                    // Retrieve mouse position and convert coordinate system\n                    // from pixel values to graph reference system\n                    var mousePosition = mapToItem(draggable, drag.x, drag.y)\n                    // Send the list of files,\n                    // to create the corresponding nodes or open another scene\n                    filesDropped(drop, mousePosition)\n                } else {\n                    errorDialog.open()\n                }\n            }\n        }\n    }\n\n    NodeActions {\n        id: nodeActions\n        uigraph: root.uigraph\n        draggable: draggable\n        nodeRepeater: nodeRepeater\n        anchors.fill: parent\n        \n        onComputeRequest: function(node) {\n            root.computeRequest([node])\n        }\n        \n        onStopComputeRequest: function(node) {\n            if (node.canBeStopped()) {\n                uigraph.stopNodeComputation(node)\n            } else if (node.canBeCanceled()) {\n                uigraph.cancelNodeComputation(node)\n            }\n        }\n\n        onDeleteDataRequest: function(node) {\n            if (nodeActionsSettings.confirmBeforeDelete) {\n                uigraph.forceNodesStatusUpdate();\n                const dialog = deleteDataDialog.createObject(\n                    root,\n                    {\n                        \"node\": node,\n                        \"deleteFollowing\": false\n                    }\n                );\n                dialog.open()\n            }\n            else {\n                uigraph.clearSelectedNodesData()\n            }\n        }\n        \n        onSubmitRequest: function(node) {\n            root.submitRequest([node])\n        }\n\n        onStopSubmitRequest: function(node) {\n            if (node.canBeStopped() || node.canBeCanceled()) {\n                uigraph.stopNode(node)\n            }\n        }\n\n        onRetrySubmitRequest: function(node) {\n            uigraph.restartJobErrorTasks(node)\n        }\n    }\n    \n    MessageDialog {\n        id: errorDialog\n\n        icon.text: MaterialIcons.error\n        icon.color: \"#F44336\"\n\n        title: \"Different File Types\"\n        text: \"Do not mix .mg files and other types of files.\"\n        standardButtons: Dialog.Ok\n\n        parent: Overlay.overlay\n\n        onAccepted: close()\n    }\n\n    // Toolbar\n    FloatingPane {\n        padding: 2\n        anchors.bottom: parent.bottom\n        RowLayout {\n            id: navigation\n            spacing: 4\n\n            // Default index for search\n            property int currentIndex: -1\n            // Fit\n            MaterialToolButton {\n                text: MaterialIcons.fullscreen\n                ToolTip.text: \"Fit\"\n                onClicked: root.fit()\n            }\n            // Auto-Layout\n            MaterialToolButton {\n                text: MaterialIcons.linear_scale\n                ToolTip.text: \"Auto-Layout\"\n                onClicked: uigraph.layout.reset()\n            }\n            // Add Backdrop\n            MaterialToolButton {\n                text: MaterialIcons.sticky_note_2\n                ToolTip.text: \"Add Backdrop\"\n                onClicked: {\n                    var selectedNodes = uigraph.getSelectedNodes()\n                    var backdrop\n                    if (selectedNodes.length > 0) {\n                        // Calculate bounding box of selected nodes\n                        var padding = uigraph.layout.gridSpacing * 0.5\n                        var minX = Number.MAX_VALUE\n                        var minY = Number.MAX_VALUE\n                        var maxX = -Number.MAX_VALUE\n                        var maxY = -Number.MAX_VALUE\n                        for (var i = 0; i < selectedNodes.length; i++) {\n                            var n = selectedNodes[i]\n                            var nw = n.nodeWidth > 0 ? n.nodeWidth : uigraph.layout.nodeWidth\n                            var nh = n.nodeHeight > 0 ? n.nodeHeight : uigraph.layout.nodeHeight\n                            minX = Math.min(minX, n.x)\n                            minY = Math.min(minY, n.y)\n                            maxX = Math.max(maxX, n.x + nw)\n                            maxY = Math.max(maxY, n.y + nh)\n                        }\n                        var bboxX = minX - padding\n                        var bboxY = minY - 2 * padding  // minus padding and title bar height\n                        var bboxW = Math.round(maxX - minX + 2 * padding)\n                        var bboxH = Math.round(maxY - minY + 2 * padding)\n                        backdrop = uigraph.addBackdropNode(Qt.point(bboxX, bboxY), bboxW, bboxH)\n                    } else {\n                        backdrop = uigraph.addNewNode(\"Backdrop\", getCenterPosition())\n                    }\n                    uigraph.selectedNode = backdrop\n                    uigraph.selectNodes([backdrop])\n                }\n            }\n\n            // Separator\n            Rectangle {\n                Layout.fillHeight: true\n                Layout.margins: 2\n                implicitWidth: 1\n                color: activePalette.window\n            }\n\n            ColorSelector {\n                id: colorSelector\n                Layout.minimumWidth: colorSelector.width\n\n                // When a Color is selected\n                onColorSelected: (color)=> {\n                    uigraph.setSelectedNodesColor(color)\n                }\n            }\n\n            // Separator\n            Rectangle {\n                Layout.fillHeight: true\n                Layout.margins: 2\n                implicitWidth: 1\n                color: activePalette.window\n            }\n\n            // Search nodes in the graph\n            SearchBar {\n                id: graphSearchBar\n  \n                toggle: true // enable toggling the actual text field by the search button\n                Layout.minimumWidth: graphSearchBar.width\n                maxWidth: 150\n\n                textField.background.opacity: 0.5\n                textField.onTextChanged: navigation.currentIndex = -1\n\n                onAccepted: {\n                    navigation.navigateForward()\n                }\n            }\n\n            MaterialToolButton {\n                text: MaterialIcons.arrow_left\n                padding: 0\n                visible: graphSearchBar.text !== \"\"\n                onClicked: navigation.navigateBackward()\n            }\n\n            MaterialToolButton {\n                id: nextArrow\n                text: MaterialIcons.arrow_right\n                padding: 0\n                visible: graphSearchBar.text !== \"\"\n                onClicked: navigation.navigateForward()\n            }\n\n            Label {\n                id: currentSearchLabel\n                text: \" \" + (navigation.currentIndex + 1) + \"/\" + filteredNodes.count\n                padding: 0\n                visible: graphSearchBar.text !== \"\"\n            }\n\n            Repeater {\n                id: filteredNodes\n                model: SortFilterDelegateModel {\n                    model: root.graph ? root.graph.nodes : undefined\n                    sortRole: \"label\"\n                    filters: [{role: \"label\", value: graphSearchBar.text}]\n                    delegate: Item {\n                        visible: false  // Hide the items to not affect the layout as the nodes model gets changes\n                        property var index_: index\n                    }\n                    function modelData(item, roleName_) {\n                        return item.model.object[roleName_]\n                    }\n                }\n            }\n\n            function navigateForward() {\n                /**\n                 * Moves the navigation index forwards and focuses on the next node as per index.\n                 */\n                if (!filteredNodes.count)\n                    return\n\n                navigation.currentIndex++\n                if (navigation.currentIndex === filteredNodes.count)\n                    navigation.currentIndex = 0\n                navigation.nextItem()\n            }\n\n            function navigateBackward() {\n                /**\n                 * Moves the navigation index backwards and focuses on the previous node as per index.\n                 */\n                if (!filteredNodes.count)\n                    return\n\n                navigation.currentIndex--\n                if (navigation.currentIndex === -1)\n                    navigation.currentIndex = filteredNodes.count - 1\n                navigation.nextItem()\n            }\n\n            function nextItem() {\n                // Compute bounding box\n                var node = nodeRepeater.getItemAt(filteredNodes.itemAt(navigation.currentIndex).index_)\n                var bbox = Qt.rect(node.x, node.y, node.width, node.height)\n                // Rescale to fit the bounding box in the view, zoom is limited to prevent huge text\n                draggable.scale = Math.min(Math.min(root.width / bbox.width, root.height / bbox.height),maxZoom)\n                // Recenter\n                draggable.x = bbox.x * draggable.scale * -1 + (root.width - bbox.width * draggable.scale) * 0.5\n                draggable.y = bbox.y * draggable.scale * -1 + (root.height - bbox.height * draggable.scale) * 0.5\n            }\n        }\n    }\n\n    function registerAttributePin(attribute, pin) {\n        root._attributeToDelegate[attribute] = pin\n    }\n\n    function unregisterAttributePin(attribute, pin) {\n        delete root._attributeToDelegate[attribute]\n    }\n\n    function boundingBox() {\n        var first = nodeRepeater.getItemAt(0)\n        if (first === null) {\n            return Qt.rect(0, 0, 0, 0)\n        }\n        var bbox = Qt.rect(first.x, first.y, first.x + first.width, first.y + first.height)\n        for (var i = 0; i < root.graph.nodes.count; ++i) {\n            var item = nodeRepeater.getItemAt(i)\n            bbox.x = Math.min(bbox.x, item.x)\n            bbox.y = Math.min(bbox.y, item.y)\n            bbox.width = Math.max(bbox.width, item.x + item.width)\n            bbox.height = Math.max(bbox.height, item.y + item.height)\n        }\n        bbox.width -= bbox.x\n        bbox.height -= bbox.y\n        return bbox\n    }\n\n    function selectionBoundingBox() {\n        /**\n         * Returns the bounding box considering the nodes which are selected.\n         * The returned bounding box starts from the Minumum x,y position to the \n         * Maximum x,y postion of the selected nodes.\n         */\n        var firstIdx = uigraph.nodeSelection.selectedIndexes[0]\n        const first = nodeRepeater.getItemAt(firstIdx.row)\n        // Bounding box of the first selected item\n        var bbox = Qt.rect(first.x, first.y, first.x + first.width, first.y + first.height)\n        // Iterate over the remaining items in the selection\n        uigraph.nodeSelection.selectedIndexes.forEach(function(idx) {\n            if(idx != firstIdx) {\n                const item = nodeRepeater.getItemAt(idx.row)\n                bbox.x = Math.min(bbox.x, item.x)\n                bbox.y = Math.min(bbox.y, item.y)\n                bbox.width = Math.max(bbox.width, item.x + item.width)\n                bbox.height = Math.max(bbox.height, item.y + item.height)\n            }\n        })\n\n        bbox.width -= bbox.x\n        bbox.height -= bbox.y\n        return bbox\n    }\n\n    // Fit graph to fill root\n    function fit() {\n        var bbox\n        // Compute bounding box\n        if (uigraph.nodeSelection.hasSelection) {\n            bbox = selectionBoundingBox()\n        }\n        else {\n            bbox = boundingBox()\n        }\n        // Rescale to fit the bounding box in the view, zoom is limited to prevent huge text\n        draggable.scale = Math.min(Math.min(root.width / bbox.width, root.height / bbox.height), maxZoom)\n        // Recenter\n        draggable.x = bbox.x * draggable.scale * -1 + (root.width - bbox.width * draggable.scale) * 0.5\n        draggable.y = bbox.y * draggable.scale * -1 + (root.height - bbox.height * draggable.scale) * 0.5\n    }\n}\n"
  },
  {
    "path": "meshroom/ui/qml/GraphEditor/GraphEditorSettings.qml",
    "content": "pragma Singleton\nimport QtCore\n\n/**\n * Persistent Settings related to the GraphEditor module.\n */\n\nSettings {\n    category: 'GraphEditor'\n    property bool showAdvancedAttributes: false\n    property bool showDefaultAttributes: true\n    property bool showModifiedAttributes: true\n    property bool showInputAttributes: true\n    property bool showOutputAttributes: true\n    property bool showLinkAttributes: true\n    property bool showNotLinkAttributes: true\n    property bool lockOnCompute: true\n}\n"
  },
  {
    "path": "meshroom/ui/qml/GraphEditor/Node.qml",
    "content": "import QtQuick\nimport QtQuick.Controls\nimport QtQuick.Layouts\nimport QtQuick.Effects\n\nimport MaterialIcons 2.2\nimport Utils 1.0\n\n/**\n * Visual representation of a Graph Node.\n */\n\nItem {\n    id: root\n\n    /// The underlying Node object\n    property variant node\n    /// Whether the node can be modified\n    property bool readOnly: node.locked\n    /// Whether the node is in compatibility mode\n    readonly property bool isCompatibilityNode: node ? node.hasOwnProperty(\"compatibilityIssue\") : false\n    /// Mouse related states\n    property bool mainSelected: false\n    property bool selected: false\n    property bool hovered: false\n    property bool dragging: mouseArea.drag.active\n    /// Node label\n    property string nodeLabel: node ? node.label : \"\"\n    /// Combined x and y\n    property point position: Qt.point(x, y)\n    /// Styling\n    readonly property color defaultColor: isCompatibilityNode ? activePalette.mid : !node.isComputableType ? \"#BA3D69\" : activePalette.base\n    property color baseColor: defaultColor\n\n    /// Shake Relevance\n    readonly property double maxAmplitude: 500.0;\n    readonly property int shakeThreshold: 5;\n    \n    property int shakeCounter: 0;\n    property bool shaking: false;\n    property int shakeDetectionInterval: 1000;  // 1 Second to complete the shake else the counter is reset\n\n    property double originalRootX: 0.0;\n    property double originalRootY: 0.0;\n\n    property int directionX: 0;\n    property int directionY: 0;\n\n    property point mousePosition: Qt.point(mouseArea.mouseX, mouseArea.mouseY)\n\n    Item {\n        id: m\n        property bool displayParams: false\n    }\n\n    // Mouse interaction related signals\n    signal pressed(var mouse)\n    signal released(var mouse)\n    signal clicked(var mouse)\n    signal doubleClicked(var mouse)\n    signal moved(var position)\n    signal shaked()\n    signal entered()\n    signal exited()\n\n    // Already connected attribute with another edge in DropArea\n    signal edgeAboutToBeRemoved(var input)\n\n    /// Emitted when child attribute pins are created\n    signal attributePinCreated(var attribute, var pin)\n    /// Emitted when child attribute pins are deleted\n    signal attributePinDeleted(var attribute, var pin)\n\n    // use node name as object name to simplify debugging\n    objectName: node ? node.name : \"\"\n\n    // initialize position with node coordinates\n    x: root.node ? root.node.x : undefined\n    y: root.node ? root.node.y : undefined\n\n    implicitHeight: childrenRect.height\n\n    SystemPalette { id: activePalette }\n\n    Connections {\n        target: root.node\n        // update x,y when node position changes\n        function onPositionChanged() {\n            root.x = root.node.x\n            root.y = root.node.y\n        }\n        function onNameChanged() {\n            // HACK: Make sure when the node name changes the node label is updated\n            root.nodeLabel = \"\"\n            // Restore binding to root.node.label\n            root.nodeLabel = Qt.binding(function() { return root.node.label; })\n        }\n    }\n\n    Timer {\n        id: shakeDetectionTimer;\n        interval: root.shakeDetectionInterval;\n        onTriggered: {\n            if (root.shaking) {\n                root.resetShaking();\n            }\n        }\n    }\n\n    function beginShaking() {\n        /**\n         * Sets up the shake related values.\n         * Enables Shake detection.\n         */\n        root.shaking = true;\n\n        // Capture the current Root's X and Y to use in detecting the movement of the node around these points\n        root.originalRootX = root.x;\n        root.originalRootY = root.y;\n    }\n\n    function resetShaking() {\n        /**\n         * Resets the shaking and the variables tracking a shake.\n         */\n        // Reset the shake counter when shaking has ended\n        root.shakeCounter = 0;\n\n        // Reset the direction detection\n        root.directionX = 0;\n        root.directionY = 0;\n    }\n\n    function endShaking() {\n        /**\n         * Resets all values related to shaking.\n         * Ends the shake detection.\n         */\n        root.shaking = false;\n\n        root.resetShaking();\n    }\n\n    function checkForShake() {\n        /**\n         * Detects a shake if a the node has been moved across the originally captured x and y positions\n         * back and forth a given number of times specified by the amplitude.\n         */\n        \n        if (!root.shaking) {\n            return;\n        }\n\n        // This indicates that the shake was either reset or we are starting from scratch\n        if (root.shakeCounter === 0 && !shakeDetectionTimer.running) {\n            shakeDetectionTimer.start();\n        }\n\n        const deltaX = root.x - root.originalRootX;\n        const deltaY = root.y - root.originalRootY;\n\n        // Check if the node has not travelled too much from the original position\n        // If so, stop detecting a shake as that might not be needed\n        if (Math.abs(deltaX) > root.maxAmplitude || Math.abs(deltaY) > root.maxAmplitude) {\n            root.endShaking();\n        }\n\n        // This checks the current direction in which the node is travelling\n        // if the node has moved on the left side of the original position -1\n        // if the node has moved on the right side of the original position +1\n\n        //        <--   Origin\n        //       [Node]   |\n        //                |   [Node]\n        //                |     -->\n        // If the motion continues to be like this 'threshold' number of times\n        // This will be considered as a shake effect\n\n        const currentDirectionX = deltaX > 0 ? 1 : -1;\n        const currentDirectionY = deltaY > 0 ? 1 : -1;\n\n        // Check if we are in the opposite direction of what was the previous direction of the Node\n        // If yes then we are propagating as a shake effect\n        if (currentDirectionX != root.directionX || currentDirectionY != root.directionY) {\n            // One shake cycle is complete, increment the counter\n            root.shakeCounter++;\n\n            // Update the original direction to be the current one\n            root.directionX = currentDirectionX;\n            root.directionY = currentDirectionY;\n        }\n\n        // The node has moved in a shake effect to match the threshold and this is causing it to be detected as a shake\n        if (root.shakeCounter > root.shakeThreshold) {\n            root.shaked();\n            // Reset the counter to detect another shake effect\n            root.resetShaking();\n        }\n    }\n\n    function formatInternalAttributesTooltip(invalidation, comment) {\n        /*\n         * Creates a string that contains the invalidation message (if it is not empty) in bold,\n         * followed by the comment message (if it exists) in regular font, separated by an empty\n         * line.\n         * Invalidation and comment messages have their tabs or line returns in plain text format replaced\n         * by their HTML equivalents.\n         */\n        let str = \"\"\n        if (invalidation !== \"\") {\n            let replacedInvalidation = node.invalidation.replace(/\\n/g, \"<br/>\").replace(/\\t/g, \"&nbsp;&nbsp;&nbsp;&nbsp;\")\n            str += \"<b>\" + replacedInvalidation + \"</b>\"\n        }\n        if (invalidation !== \"\" && comment !== \"\") {\n            str += \"<br/><br/>\"\n        }\n        if (comment !== \"\") {\n            let replacedComment = node.comment.replace(/\\n/g, \"<br/>\").replace(/\\t/g, \"&nbsp;&nbsp;&nbsp;&nbsp;\")\n            str += replacedComment\n        }\n        return str\n    }\n\n    // Used to generate list of node's label sharing the same uid\n    function generateDuplicateList() {\n        let str = \"<b>Shares internal folder (data) with:</b>\"\n        for (let i = 0; i < node.duplicates.count; ++i) {\n            if (i % 5 === 0)\n                str += \"<br>\"\n\n            const currentNode = node.duplicates.at(i)\n\n            if (i === node.duplicates.count - 1) {\n                str += currentNode.nameToLabel(currentNode.name)\n                return str\n            }\n\n            str += (currentNode.nameToLabel(currentNode.name) + \", \")\n        }\n        return str\n    }\n\n    function updateChildPin(attribute, parentPins, pin) {\n        /*\n         * Update the pin of a child attribute: if the attribute is enabled and its parent is\n         * a GroupAttribute, the visibility is determined based on the parent pin's \"expanded\" state,\n         * using the \"parentPins\" map to access the status.\n         * If the current pin is also a GroupAttribute and is expanded while its newly \"visible\" state\n         * is false, it is reset.\n         */\n        if (Boolean(attribute.enabled)) {\n            // If the parent is a GroupAttribute, use the status of the parent's pin to determine visibility\n            // UNLESS the child attribute is already connected with a visible edge\n            if (attribute.root && attribute.root.baseType === \"GroupAttribute\") {\n                var visible = Boolean(parentPins.get(attribute.root.name))\n                if (!visible && parentPins.has(attribute.name) && parentPins.get(attribute.name) === true) {\n                    parentPins.set(attribute.name, false)\n                    pin.expanded = false\n                }\n                return visible\n            }\n            return true\n        }\n        return false\n    }\n\n    function generateAttributesModel(isOutput, parentPins) {\n        if (!node) {\n            return undefined\n        }\n\n        const attributes = []\n        for (let i = 0; i < node.attributes.count; i++) {\n            let attr = node.attributes.at(i)\n            if (attr.isOutput == isOutput) {\n                // Add the attribute to the model\n                attributes.push(attr)\n                if (attr.baseType === \"GroupAttribute\") {\n                    // If it is a GroupAttribute, initialize its pin status\n                    parentPins.set(attr.name, false)\n                }\n\n                // Check and add any child this attribute might have\n                attr.flatStaticChildren.forEach((child) =>\n                    {\n                        attributes.push(child)\n                        if (child.baseType === \"GroupAttribute\") {\n                            parentPins.set(child.name, false)\n                        }\n                    }\n                )\n            }\n        }\n\n        return attributes\n    }\n\n    // Main Layout\n    MouseArea {\n        id: mouseArea\n        width: parent.width\n        height: body.height\n        drag.target: root\n        // Small drag threshold to avoid moving the node by mistake\n        drag.threshold: 2\n        hoverEnabled: true\n        acceptedButtons: Qt.LeftButton | Qt.RightButton\n        onClicked: (mouse) => root.clicked(mouse)\n        onDoubleClicked: (mouse) => root.doubleClicked(mouse)\n        onEntered: root.entered()\n        onExited: root.exited()\n        drag.onActiveChanged: {\n            if (!drag.active) {\n                root.moved(Qt.point(root.x, root.y))\n            }\n        }\n\n        cursorShape: drag.active ? Qt.ClosedHandCursor : Qt.ArrowCursor\n\n        onPressed: function(mouse) {\n            root.pressed(mouse);\n            // Begin shake detection\n            root.beginShaking();\n        }\n\n        onReleased: function(mouse) {\n            root.released(mouse);\n            // End shake detection\n            root.endShaking();\n        }\n\n        // Selection border\n        Rectangle {\n            anchors.fill: nodeContent\n            anchors.margins: -border.width\n            visible: root.mainSelected || root.hovered || root.selected\n            border.width: {\n                if(root.mainSelected)\n                    return 3\n                if(root.selected)\n                    return 2.5\n                return 2\n            }\n            border.color: {\n                if(root.mainSelected)\n                    return activePalette.highlight\n                if(root.selected)\n                    return Qt.darker(activePalette.highlight, 1.2)\n                return Qt.lighter(activePalette.base, 3)\n            }\n            opacity: 0.9\n            radius: background.radius + border.width\n            color: \"transparent\"\n        }\n\n        Rectangle {\n            id: background\n            anchors.fill: nodeContent\n            color: node.color === \"\" ? Qt.lighter(activePalette.base, 1.4) : node.color\n            layer.enabled: true\n            layer.effect: MultiEffect {\n                shadowColor: activePalette.shadow\n                // Performance tip: Reduce blurMax (not shadowBlur) to minimize shadow blur.\n                shadowBlur: 1.0  // So we keep shadowBlur at 1.0.\n                shadowEnabled: true\n                blurMax: 4  // large values could impact performances\n            }\n            radius: 3\n            opacity: 0.85\n        }\n\n        Rectangle {\n            id: nodeContent\n            width: parent.width\n            height: childrenRect.height\n            color: \"transparent\"\n\n            // Data Layout\n            Column {\n                id: body\n                width: parent.width\n\n                // Header\n                Rectangle {\n                    id: header\n                    width: parent.width\n                    height: headerLayout.height\n                    color: root.baseColor\n                    radius: background.radius\n\n                    // Fill header's bottom radius\n                    Rectangle {\n                        width: parent.width\n                        height: parent.radius\n                        anchors.bottom: parent.bottom\n                        color: parent.color\n                        z: -1\n                    }\n\n                    // Header Layout\n                    RowLayout {\n                        id: headerLayout\n                        width: parent.width\n                        spacing: 0\n\n                        // Node Name\n                        Label {\n                            id: nodeLabel\n                            Layout.fillWidth: true\n                            text: root.nodeLabel\n                            padding: 4\n                            color: root.mainSelected ? activePalette.highlightedText : activePalette.text\n                            elide: Text.ElideMiddle\n                            font.pointSize: 8\n                        }\n\n                        // Node State icons\n                        RowLayout {\n                            Layout.fillWidth: true\n                            Layout.alignment: Qt.AlignRight\n                            Layout.rightMargin: 2\n                            spacing: 2\n\n                            // CompatibilityBadge icon for CompatibilityNodes\n                            Loader {\n                                active: root.isCompatibilityNode\n                                sourceComponent: CompatibilityBadge {\n                                    sourceComponent: iconDelegate\n                                    canUpgrade: root.node.canUpgrade\n                                    issueDetails: root.node.issueDetails\n                                }\n                            }\n\n                            // Data sharing indicator\n                            // Note: for an unknown reason, there are some performance issues with the UI refresh.\n                            // Example: a node duplicated 40 times will be slow while creating another identical node\n                            // (sharing the same uid) will not be as slow. If save, quit and reload, it will become slow.\n                            MaterialToolButton {\n                                property string baseText: \"<b>Shares internal folder (data) with other node(s). Hold click for details.</b>\"\n                                property string toolTipText: visible ? baseText : \"\"\n                                visible: node.hasDuplicates\n                                text: MaterialIcons.layers\n                                font.pointSize: 7\n                                padding: 2\n                                palette.text: Colors.sysPalette.text\n                                ToolTip.text: toolTipText\n\n                                onPressed: {\n                                    offsetReleased.running = false\n                                    toolTipText = visible ? generateDuplicateList() : \"\"\n                                }\n                                onReleased: {\n                                    toolTipText = \"\"\n                                    offsetReleased.running = true\n                                }\n                                onCanceled: released()\n\n                                // Used for a better user experience with the button\n                                // Avoid to change the text too quickly\n                                Timer {\n                                    id: offsetReleased\n                                    interval: 750\n                                    running: false\n                                    repeat: false\n                                    onTriggered: parent.toolTipText = visible ? parent.baseText : \"\"\n                                }\n                            }\n\n                            // Submitted externally indicator\n                            MaterialLabel {\n                                visible: node.isExternal\n                                text: MaterialIcons.cloud\n                                padding: 2\n                                font.pointSize: 7\n                                palette.text: Colors.sysPalette.text\n                                ToolTip.text: \"Computed Externally\"\n                            }\n\n                            // Lock indicator\n                            MaterialLabel {\n                                visible: root.readOnly\n                                text: MaterialIcons.lock\n                                padding: 2\n                                font.pointSize: 7\n                                palette.text: \"red\"\n                                ToolTip.text: \"Locked\"\n                            }\n\n                            MaterialLabel {\n                                id: nodeComment\n                                visible: node.comment !== \"\" || node.invalidation !== \"\"\n                                text: MaterialIcons.comment\n                                padding: 2\n                                font.pointSize: 7\n\n                                ToolTip {\n                                    id: nodeCommentTooltip\n                                    parent: header\n                                    visible: nodeCommentMA.containsMouse && nodeComment.visible\n                                    text: formatInternalAttributesTooltip(node.invalidation, node.comment)\n                                    implicitWidth: 400 // Forces word-wrap for long comments but the tooltip will be bigger than needed for short comments\n                                    delay: 300\n\n                                    // Relative position for the tooltip to ensure we will not get stuck in a case where it starts appearing over the mouse's\n                                    // position because it is a bit long and cutting off the hovering of the mouse area (which leads to the tooltip beginning\n                                    // to appear and immediately disappearing, over and over again)\n                                    x: implicitWidth / 2.5\n                                }\n\n                                MouseArea {\n                                    // If the node header is hovered, comments may be displayed\n                                    id: nodeCommentMA\n                                    anchors.fill: parent\n                                    hoverEnabled: true\n                                }\n                            }\n\n                            MaterialLabel {\n                                id: nodeImageOutput\n                                visible: (node.hasImageOutput || node.has3DOutput || node.hasSequenceOutput || node.hasTextOutput)\n                                text: MaterialIcons.visibility\n                                padding: 2\n                                font.pointSize: 7\n                                property bool displayable: !node.isComputableType || (node.chunks.count > 0 && ([\"SUCCESS\"].includes(node.globalStatus)))\n                                color: displayable ? palette.text : Qt.darker(palette.text, 1.8)\n\n                                ToolTip {\n                                    id: nodeImageOutputTooltip\n                                    parent: header\n                                    visible: nodeImageOutputMA.containsMouse && nodeImageOutput.visible\n                                    text: {\n                                        if ((node.hasImageOutput || node.hasSequenceOutput) && !node.has3DOutput)\n                                            return nodeImageOutput.displayable ? \"Double-click on this node to load its outputs in the Image Viewer.\" : \"This node has image outputs.\"\n                                        else if (node.has3DOutput && !node.hasImageOutput && !node.hasSequenceOutput)\n                                            return nodeImageOutput.displayable ? \"Double-click on this node to load its outputs in the 3D Viewer.\" : \"This node has 3D outputs.\"\n                                        else if (node.hasTextOutput && !node.hasImageOutput && !node.hasSequenceOutput && !node.has3DOutput)\n                                            return nodeImageOutput.displayable ? \"Double-click on this node to load its outputs in the Text Viewer.\" : \"This node has text outputs.\"\n                                        else  // Handle case where a node might have both 2D and 3D outputs\n                                            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.\"\n                                    }\n                                    implicitWidth: 500\n                                    delay: 300\n\n                                    // Relative position for the tooltip to ensure we will not get stuck in a case where it starts appearing over the mouse's\n                                    // position because it is a bit long and cutting off the hovering of the mouse area (which leads to the tooltip beginning\n                                    // to appear and immediately disappearing, over and over again)\n                                    x: implicitWidth / 2.5\n                                }\n\n                                MouseArea {\n                                    // If the node header is hovered, comments may be displayed\n                                    id: nodeImageOutputMA\n                                    anchors.fill: parent\n                                    hoverEnabled: true\n                                }\n                            }\n                        }\n                    }\n                }\n\n                // Node Chunks\n                NodeChunks {\n                    visible: node.isComputableType\n                    targetNode: node\n                    defaultColor: Colors.sysPalette.mid\n                    implicitHeight: 3\n                    width: parent.width\n                    model: {\n                        if (node && node.chunksCreated)\n                            return node.chunks\n                        else if (node && !node.chunksCreated)\n                            return node.chunkPlaceholder\n\n                        return undefined\n                    }\n\n                    Rectangle {\n                        anchors.fill: parent\n                        color: Colors.sysPalette.mid\n                        z: -1\n                    }\n                }\n\n                // Vertical Spacer\n                Item { width: parent.width; height: 2 }\n\n                // Input/Output Attributes\n                Item {\n                    id: nodeAttributes\n                    width: parent.width - 2\n                    height: childrenRect.height\n                    anchors.horizontalCenter: parent.horizontalCenter\n\n                    Column {\n                        id: attributesColumn\n                        width: parent.width\n                        spacing: 5\n                        bottomPadding: 2\n\n                        Column {\n                            id: outputs\n                            width: parent.width\n                            spacing: 3\n\n                            property var parentPins: new Map()\n                            signal parentPinsUpdated()\n\n                            Repeater {\n                                model: root.generateAttributesModel(true, outputs.parentPins)  // isOutput = true\n\n                                delegate: Loader {\n                                    id: outputLoader\n\n                                    active: Boolean(modelData.isOutput && modelData.desc.visible)\n                                    visible: {\n                                        if (Boolean(modelData.enabled || modelData.hasAnyOutputLinks || modelData.hasAnyInputLinks)) {\n                                            if (modelData.root && modelData.root.baseType === \"GroupAttribute\") {\n                                                return Boolean(outputs.parentPins.get(modelData.root.name) ||\n                                                               modelData.hasAnyOutputLinks ||\n                                                               modelData.hasAnyInputLinks)\n                                            }\n                                            return true\n                                        }\n                                        return false\n                                    }\n\n                                    anchors.right: parent.right\n                                    width: outputs.width\n\n                                    Connections {\n                                        target: outputs\n\n                                        function onParentPinsUpdated() {\n                                            visible = updateChildPin(modelData, outputs.parentPins, outputLoader.item)\n                                        }\n                                    }\n\n                                    sourceComponent: AttributePin {\n                                        id: outPin\n                                        nodeItem: root\n                                        attribute: modelData\n\n                                        property real globalX: root.x + nodeAttributes.x + outputs.x + outputLoader.x + outPin.x\n                                        property real globalY: root.y + nodeAttributes.y + outputs.y + outputLoader.y + outPin.y\n\n                                        onIsConnectedChanged: function() {\n                                            outputs.parentPinsUpdated()\n                                        }\n\n                                        onPressed: function(mouse) {\n                                            root.pressed(mouse)\n                                        }\n\n                                        onClicked: function() {\n                                            expanded = !expanded\n                                            if (outputs.parentPins.has(modelData.name)) {\n                                                outputs.parentPins.set(modelData.name, expanded)\n                                                outputs.parentPinsUpdated()\n                                            }\n                                        }\n\n                                        onEdgeAboutToBeRemoved: function(input) {\n                                            root.edgeAboutToBeRemoved(input)\n                                        }\n\n                                        Component.onCompleted: attributePinCreated(attribute, outPin)\n                                        onChildPinCreated: attributePinCreated(childAttribute, outPin)\n                                        Component.onDestruction: attributePinDeleted(attribute, outPin)\n                                        onChildPinDeleted: attributePinDeleted(childAttribute, outPin)\n                                    }\n                                }\n                            }\n                        }\n\n                        Column {\n                            id: inputs\n                            width: parent.width\n                            spacing: 3\n\n                            property var parentPins: new Map()\n                            signal parentPinsUpdated()\n\n                            Repeater {\n                                model: root.generateAttributesModel(false, inputs.parentPins)  // isOutput = false\n\n                                delegate: Loader {\n                                    id: inputLoader\n\n                                    active: !modelData.isOutput && modelData.exposed && modelData.desc.visible\n                                    visible: {\n                                        if (Boolean(modelData.enabled)) {\n                                            if (modelData.root && modelData.root.baseType === \"GroupAttribute\") {\n                                                return Boolean(inputs.parentPins.get(modelData.root.name) ||\n                                                               modelData.hasAnyOutputLinks ||\n                                                               modelData.hasAnyInputLinks)\n                                            }\n                                            return true\n                                        }\n                                        return false\n                                    }\n\n                                    width: inputs.width\n\n                                    Connections {\n                                        target: inputs\n\n                                        function onParentPinsUpdated() {\n                                            visible = updateChildPin(modelData, inputs.parentPins, inputLoader.item)\n                                        }\n                                    }\n\n                                    sourceComponent: AttributePin {\n                                        id: inPin\n                                        nodeItem: root\n                                        attribute: modelData\n\n                                        property real globalX: root.x + nodeAttributes.x + inputs.x + inputLoader.x + inPin.x\n                                        property real globalY: root.y + nodeAttributes.y + inputs.y + inputLoader.y + inPin.y\n\n                                        onIsConnectedChanged: function() {\n                                            inputs.parentPinsUpdated()\n                                        }\n\n                                        readOnly: Boolean(root.readOnly || modelData.isReadOnly)\n                                        Component.onCompleted: attributePinCreated(attribute, inPin)\n                                        Component.onDestruction: attributePinDeleted(attribute, inPin)\n\n                                        onPressed: function(mouse) {\n                                            root.pressed(mouse)\n                                        }\n\n                                        onClicked: function() {\n                                            expanded = !expanded\n                                            if (inputs.parentPins.has(modelData.name)) {\n                                                inputs.parentPins.set(modelData.name, expanded)\n                                                inputs.parentPinsUpdated()\n                                            }\n                                        }\n\n                                        onEdgeAboutToBeRemoved: function(input) {\n                                            root.edgeAboutToBeRemoved(input)\n                                        }\n\n                                        onChildPinCreated: function(childAttribute, inPin) { attributePinCreated(childAttribute, inPin) }\n                                        onChildPinDeleted: function(childAttribute, inPin) { attributePinDeleted(childAttribute, inPin) }\n                                    }\n                                }\n                            }\n                        }\n\n                        // Vertical Spacer\n                        Rectangle {\n                            height: inputParams.height > 0 ? 3 : 0\n                            visible: (height == 3)\n                            Behavior on height { PropertyAnimation {easing.type: Easing.Linear} }\n                            width: parent.width\n                            color: Colors.sysPalette.mid\n                            MaterialToolButton {\n                                text: \" \"\n                                width: parent.width\n                                height: parent.height\n                                padding: 0\n                                spacing: 0\n                                anchors.margins: 0\n                                font.pointSize: 6\n                                onClicked: {\n                                    m.displayParams = ! m.displayParams\n                                }\n                            }\n                        }\n\n                        Rectangle {\n                            id: inputParamsRect\n                            width: parent.width\n                            height: childrenRect.height\n                            color: \"transparent\"\n\n                            Column {\n                                id: inputParams\n                                width: parent.width\n                                spacing: 3\n\n                                property var parentPins: new Map()\n                                signal parentPinsUpdated()\n\n                                Repeater {\n                                    model: root.generateAttributesModel(false, inputParams.parentPins)  // isOutput = false\n\n                                    delegate: Loader {\n                                        id: paramLoader\n\n                                        active: !modelData.isOutput && !modelData.exposed && modelData.desc.visible\n                                        visible: {\n                                            if (Boolean(modelData.enabled || modelData.hasAnyOutputLinks || modelData.hasAnyInputLinks)) {\n                                                if (modelData.root && modelData.root.baseType === \"GroupAttribute\") {\n                                                    return Boolean(inputParams.parentPins.get(modelData.root.name) ||\n                                                                   modelData.hasAnyOutputLinks ||\n                                                                   modelData.hasAnyInputLinks)\n                                                }\n                                                return true\n                                            }\n                                            return false\n                                        }\n\n                                        property bool isFullyActive: Boolean(m.displayParams ||\n                                                                             modelData.hasAnyInputLinks ||\n                                                                             modelData.hasAnyOutputLinks)\n                                        width: parent.width\n\n                                        Connections {\n                                            target: inputParams\n\n                                            function onParentPinsUpdated() {\n                                                visible = updateChildPin(modelData, inputParams.parentPins, paramLoader.item)\n                                            }\n                                        }\n\n                                        sourceComponent: AttributePin {\n                                            id: inParamsPin\n                                            nodeItem: root\n                                            attribute: modelData\n\n                                            property real globalX: root.x + nodeAttributes.x + inputParamsRect.x + paramLoader.x + inParamsPin.x\n                                            property real globalY: root.y + nodeAttributes.y + inputParamsRect.y + paramLoader.y + inParamsPin.y\n\n                                            onIsConnectedChanged: function() {\n                                                inputParams.parentPinsUpdated()\n                                            }\n\n                                            height: isFullyActive ? childrenRect.height : 0\n                                            Behavior on height { PropertyAnimation {easing.type: Easing.Linear} }\n                                            visible: (height == childrenRect.height)\n\n                                            readOnly: Boolean(root.readOnly || modelData.isReadOnly)\n                                            Component.onCompleted: attributePinCreated(attribute, inParamsPin)\n                                            Component.onDestruction: attributePinDeleted(attribute, inParamsPin)\n\n                                            onPressed: function(mouse) {\n                                                root.pressed(mouse)\n                                            }\n\n                                            onClicked: function() {\n                                                expanded = !expanded\n                                                if (inputParams.parentPins.has(modelData.name)) {\n                                                    inputParams.parentPins.set(modelData.name, expanded)\n                                                    inputParams.parentPinsUpdated()\n                                                }\n                                            }\n\n                                            onEdgeAboutToBeRemoved: function(input) {\n                                                root.edgeAboutToBeRemoved(input)\n                                            }\n\n                                            onChildPinCreated: function(childAttribute, inParamsPin) { attributePinCreated(childAttribute, inParamsPin) }\n                                            onChildPinDeleted: function(childAttribute, inParamsPin) { attributePinDeleted(childAttribute, inParamsPin) }\n                                        }\n                                    }\n                                }\n                            }\n                        }\n\n                        MaterialToolButton {\n                            text: root.hovered ? (m.displayParams ? MaterialIcons.arrow_drop_up : MaterialIcons.arrow_drop_down) : \" \"\n                            Layout.alignment: Qt.AlignBottom\n                            width: parent.width\n                            height: 5\n                            padding: 0\n                            spacing: 0\n                            anchors.margins: 0\n                            font.pointSize: 10\n                            onClicked: {\n                                m.displayParams = ! m.displayParams\n                            }\n                        }\n                    }\n                }\n            }\n        }\n    }\n}\n"
  },
  {
    "path": "meshroom/ui/qml/GraphEditor/NodeChunks.qml",
    "content": "import QtQuick\n\nimport Utils 1.0\n\nListView {\n    id: root\n    interactive: false\n    property bool highlightChunks: true\n\n    SystemPalette { id: activePalette }\n\n    property var targetNode: null\n\n    property color defaultColor: Qt.darker(activePalette.window, 1.1)\n    property real chunkHeight: height\n    property int modelSize: model ? model.count : 0\n    property bool modelIsBig: (3 * modelSize >= width)\n    property real chunkWidth: {\n        if (modelSize == 0) return 0\n        return (width / modelSize) - spacing\n    }\n\n    orientation: ListView.Horizontal\n\n    // If we have enough space, add one pixel margin between chunks\n    spacing: modelIsBig ? 0 : 1\n    delegate: Rectangle {\n        id: chunkDelegate\n        height: root.chunkHeight\n        width: root.chunkWidth\n        property var chunkColor: Colors.getChunkColor(object, { \"NONE\": root.defaultColor })\n        color: {\n            if (!highlightChunks || modelSize == 1)\n                return chunkColor\n            if (index % 2 == 0)\n                return Qt.lighter(chunkColor, 1.1)\n            else\n                return Qt.darker(chunkColor, 1.1)\n        }\n    }\n}\n"
  },
  {
    "path": "meshroom/ui/qml/GraphEditor/NodeDocumentation.qml",
    "content": "import QtQuick\nimport QtQuick.Controls\nimport QtQuick.Layouts\n\nimport Controls 1.0\n\n/**\n * Displays Node documentation\n */\n\nFocusScope {\n    id: root\n\n    property variant node\n\n    SystemPalette { id: activePalette }\n\n    ScrollView {\n        width: parent.width\n        height: parent.height\n        ScrollBar.vertical.policy: ScrollBar.AlwaysOn\n        ScrollBar.horizontal.policy: ScrollBar.AlwaysOff\n        clip: true\n\n        ColumnLayout {\n            id: nodeDocColumnLayout\n            property real keyColumnWidth: 10.0 * Qt.application.font.pixelSize\n\n            Component {\n                id: nodeInfoItem\n                Rectangle {\n                    color: activePalette.window\n                    width: parent.width\n                    height: childrenRect.height\n                    RowLayout {\n                        width: parent.width\n                        Rectangle {\n                            id: nodeInfoKey\n                            anchors.margins: 2\n                            color: Qt.darker(activePalette.window, 1.1)\n                            Layout.preferredWidth: nodeDocColumnLayout.keyColumnWidth\n                            Layout.minimumWidth: 0.2 * parent.width\n                            Layout.maximumWidth: 0.8 * parent.width\n                            Layout.fillWidth: false\n                            Layout.fillHeight: true\n                            Label {\n                                text: modelData.key\n                                font.capitalization: Font.Capitalize\n                                anchors.fill: parent\n                                anchors.top: parent.top\n                                topPadding: 4\n                                leftPadding: 6\n                                verticalAlignment: TextEdit.AlignTop\n                                elide: Text.ElideRight\n                            }\n                        }\n                        // Drag handle for resizing\n                        Rectangle {\n                            width: 2\n                            Layout.fillHeight: true\n                            color: \"transparent\"\n                            MouseArea {\n                                anchors.fill: parent\n                                anchors.margins: -2\n                                cursorShape: Qt.SizeHorCursor\n                                drag {\n                                    target: parent\n                                    axis: Drag.XAxis\n                                    threshold: 0\n                                    // Not required\n                                    minimumX: 0.2 * nodeDocColumnLayout.width\n                                    maximumX: 0.8 * nodeDocColumnLayout.width\n                                }\n                                onPositionChanged: (mouse)=> {\n                                    nodeDocColumnLayout.keyColumnWidth = parent.x\n                                }\n                            }\n\n                        }\n                        TextArea {\n                            id: nodeInfoValue\n                            text: modelData.value\n                            anchors.margins: 2\n                            Layout.fillWidth: true\n                            Layout.fillHeight: true\n                            wrapMode: Label.WrapAtWordBoundaryOrAnywhere\n                            textFormat: TextEdit.PlainText\n                            readOnly: true\n                            selectByMouse: true\n                            background: Rectangle { anchors.fill: parent; color: Qt.darker(activePalette.window, 1.05) }\n                        }\n                    }\n                }\n            }\n\n            ListView {\n                id: nodeInfoListView\n                width: parent.width\n                height: childrenRect.height\n                Layout.preferredWidth: width\n                spacing: 3\n                model: node.nodeInfo\n                delegate: nodeInfoItem\n            }\n\n            TextEdit {\n                id: documentationText\n                padding: 8\n                topPadding: 20\n                Layout.alignment: Qt.AlignTop | Qt.AlignLeft\n                Layout.preferredWidth: width\n                width: parent.parent.parent.width\n                textFormat: TextEdit.MarkdownText\n                selectByMouse: true\n                selectionColor: activePalette.highlight\n                color: activePalette.text\n                text: node ? node.documentation : \"\"\n                wrapMode: TextEdit.Wrap\n            }\n        }\n    }\n}\n"
  },
  {
    "path": "meshroom/ui/qml/GraphEditor/NodeEditor.qml",
    "content": "import QtQuick\nimport QtQuick.Controls\nimport QtQuick.Layouts\n\nimport Controls 1.0\nimport MaterialIcons 2.2\nimport Utils 1.0\nimport Shapes 1.0\n\n/**\n * NodeEditor allows to visualize and edit the parameters of a Node.\n * It mainly provides an attribute editor and a log inspector.\n */\n\nPanel {\n    id: root\n\n    property variant node\n    property string globalStatus : node !== null ? node.globalStatus : \"\"\n    property bool readOnly: false\n    property bool isCompatibilityNode: node && node.compatibilityIssue !== undefined\n    property string nodeStartDateTime: \"\"\n\n    property variant nodeName: node !== null ? node.name : undefined\n    property string displayNodeName: node !== null ? node.name : \"\"\n    property string validatedNodeName: displayNodeName\n    property string displayNodeType: \"\"\n\n    function updateNodeNameDisplay() {\n        if (_currentScene.selectedNode) {\n            const nodeName = _currentScene.selectedNode.name\n            root.displayNodeName = nodeName\n            root.validatedNodeName = nodeName\n            // Set the display node type only if it is not contained in the node name\n            const nodeType = _currentScene.selectedNode.nodeType\n            root.displayNodeType = nodeName.startsWith(nodeType + \"_\") ? \"\" : nodeType\n        }\n    }\n\n    Connections {\n        target: _currentScene\n        function onSelectedNodeChanged() {\n            updateNodeNameDisplay()\n        }\n    }\n\n    onNodeNameChanged: {\n        updateNodeNameDisplay()\n    }\n\n    signal attributeDoubleClicked(var mouse, var attribute)\n    signal inAttributeClicked(var srcItem, var mouse, var inAttributes)\n    signal outAttributeClicked(var srcItem, var mouse, var outAttributes)\n    signal showAttributeInViewer(var attribute)\n    signal upgradeRequest()\n\n    title: \"Node\" + (node !== null ? \" - <b>\" + node.label + \"</b>\" + (node.label !== node.defaultLabel ? \" (\" + node.defaultLabel + \")\" : \"\") : \"\")\n    icon: MaterialLabel { text: MaterialIcons.tune }\n\n    onGlobalStatusChanged: {\n        nodeStartDateTime = \"\"\n        if (node !== null && node.isRunning()) {\n            timer.start()\n        }\n        else {\n            timer.stop()\n            if (node !== null && (node.isFinishedOrRunning() || globalStatus == \"ERROR\")) {\n                computationInfo.text = Format.sec2timeStr(node.elapsedTime)\n            }\n            else {\n                computationInfo.text =  \"\"\n            }\n        }\n    }\n\n    function refresh() {\n        /**\n         * Refresh properties of the Node Editor.\n         */\n        // Reset tab bar's current index\n        tabBar.currentIndex = 0;\n    }\n\n    // Function to validate and apply node name change\n    function validateNodeNameChange(name) {\n        if (root.node && name.trim() !== \"\") {\n            const newNodeName = _currentScene.renameNode(_currentScene.selectedNode, name.trim())\n            if (newNodeName === \"\") {\n                root.displayNodeName = root.nodeName\n                root.validatedNodeName = root.nodeName\n            } else {\n                root.displayNodeName = newNodeName\n                root.validatedNodeName = newNodeName\n            }\n        }\n    }\n    function cancelNodeNameChange() {\n        // HACK: Set to an empty string to force the text to be set to the previous value.\n        root.displayNodeName = \"\"\n        root.displayNodeName = root.validatedNodeName\n    }\n\n    // Add custom title component for editing\n    titleComponent: Component {\n        RowLayout {\n            spacing: 4\n\n            Label {\n                text: root.node === null ? \"NodeEditor\" : \"Node -\"\n                topPadding: 4\n                bottomPadding: 4\n                rightPadding: 0\n            }\n\n            TextField {\n                id: nodeNameField\n                visible: root.node !== null\n                text: root.displayNodeName\n                // For some reason the validator does not always work\n                validator: RegularExpressionValidator { regularExpression: /^[0-9A-Za-z]+$/ }\n                font.bold: true\n                readOnly: true\n                selectByMouse: false\n                verticalAlignment: Text.AlignVCenter\n                topPadding: 4\n                bottomPadding: 4\n                leftPadding: 0\n\n                background: Rectangle {\n                    color: nodeNameField.readOnly ? \"transparent\" : root.palette.base\n                    border.color: nodeNameField.readOnly ? \"transparent\" : root.palette.highlight\n                    border.width: 1\n                    radius: 2\n                }\n\n                function refreshText() {\n                    nodeNameField.text = Qt.binding(function() { return root.displayNodeName })\n                }\n\n                MouseArea {\n                    anchors.fill: parent\n                    enabled: nodeNameField.readOnly\n                    onDoubleClicked: {\n                        if (root.node && !root.node.locked) {\n                            nodeNameField.readOnly = false\n                            nodeNameField.selectByMouse = true\n                            nodeNameField.forceActiveFocus()\n                            nodeNameField.selectAll()\n                        }\n                    }\n                }\n\n                Keys.onReturnPressed: {\n                    if (!readOnly) {\n                        root.validateNodeNameChange(text)\n                        nodeNameField.refreshText()\n                        readOnly = true\n                        selectByMouse = false\n                    }\n                }\n\n                Keys.onEnterPressed: {\n                    if (!readOnly) {\n                        root.validateNodeNameChange(text)\n                        nodeNameField.refreshText()\n                        readOnly = true\n                        selectByMouse = false\n                    }\n                }\n\n                Keys.onEscapePressed: {\n                    if (!readOnly) {\n                        root.cancelNodeNameChange()\n                        nodeNameField.refreshText()\n                        readOnly = true\n                        selectByMouse = false\n                    }\n                }\n\n                onActiveFocusChanged: {\n                    if (!activeFocus && !readOnly) {\n                        // Focus lost without pressing Enter - discard changes\n                        root.cancelNodeNameChange()\n                        nodeNameField.refreshText()\n                        readOnly = true\n                        selectByMouse = false\n                    }\n                }\n\n                Connections {\n                    target: _currentScene\n                    function onSelectedNodeChanged() {\n                        if (!activeFocus && !readOnly) {\n                            root.cancelNodeNameChange()\n                            nodeNameField.refreshText()\n                            nodeNameField.readOnly = true\n                            nodeNameField.selectByMouse = false\n                        }\n                    }\n                }\n            }\n\n            // Show node type if the node name does not start with \"nodeType_\"\n            Label {\n                text: \"(\" + root.displayNodeType + \")\"\n                visible: root.displayNodeType !== \"\" && _currentScene.selectedNode\n                topPadding: 4\n                bottomPadding: 4\n            }\n        }\n    }\n\n    headerBar: RowLayout {\n        Label {\n            id: computationInfo\n            color: node && node.isComputableType ? Colors.statusColors[node.globalStatus] : palette.text\n            Timer {\n                id: timer\n                interval: 2500\n                triggeredOnStart: true\n                repeat: true\n                running: node !== null && node.isRunning()\n                onTriggered: {\n                    if (nodeStartDateTime === \"\") {\n                        nodeStartDateTime = new Date(node.getStartDateTime()).getTime()\n                    }\n                    var now = new Date().getTime()\n                    parent.text = Format.sec2timeStr((now-nodeStartDateTime)/1000)\n                }\n            }\n            padding: 2\n            font.italic: true\n            visible: {\n                if (node !== null) {\n                    if (node.isComputableType && (node.isFinishedOrRunning() || node.isSubmittedOrRunning() || node.globalStatus==\"ERROR\")) {\n                        return true\n                    }\n                }\n                return false\n            }\n\n            ToolTip.text: {\n                if (node !== null && (node.isFinishedOrRunning() || (node.isSubmittedOrRunning() && node.elapsedTime > 0))) {\n                    var longestChunkTime = getLongestChunkTime(node.chunks)\n                    if (longestChunkTime > 0)\n                        return \"Longest chunk: \" + Format.sec2timeStr(longestChunkTime) + \" (\" + node.chunks.count + \" chunks)\"\n                    else\n                        return \"\"\n                } else {\n                    return \"\"\n                }\n            }\n            ToolTip.visible: ToolTip.text ? runningTimeMa.containsMouse : false\n            MouseArea {\n                id: runningTimeMa\n                anchors.fill: parent\n                hoverEnabled: true\n            }\n\n            function getLongestChunkTime(chunks) {\n                if (chunks.count <= 1)\n                    return 0\n\n                var longestChunkTime = 0\n                for (var i = 0; i < chunks.count; i++) {\n                    var elapsedTime = chunks.at(i).elapsedTime\n                    longestChunkTime = elapsedTime > longestChunkTime ? elapsedTime : longestChunkTime\n                }\n                return longestChunkTime\n            }\n        }\n\n        SearchBar {\n            id: searchBar\n            toggle: true  // Enable toggling the actual text field by the search button\n            Layout.minimumWidth: searchBar.width\n            maxWidth: 150\n            enabled: tabBar.currentIndex === 0 || tabBar.currentIndex === 6\n        }\n\n        MaterialToolButton {\n            text: MaterialIcons.more_vert\n            font.pointSize: 11\n            padding: 2\n            onClicked: settingsMenu.open()\n            checkable: true\n            checked: settingsMenu.visible\n            Menu {\n                id: settingsMenu\n                y: parent.height\n\n                Menu {\n                    id: filterAttributesMenu\n                    title: \"Filter Attributes\"\n                    RowLayout {\n                        CheckBox {\n                            id: outputToggle\n                            text: \"Output\"\n                            checkable: true\n                            checked: GraphEditorSettings.showOutputAttributes\n                            onClicked: GraphEditorSettings.showOutputAttributes = !GraphEditorSettings.showOutputAttributes \n                            enabled: tabBar.currentIndex === 0\n                        }\n                        CheckBox {\n                            id: inputToggle\n                            text: \"Input\"\n                            checkable: true\n                            checked: GraphEditorSettings.showInputAttributes\n                            onClicked: GraphEditorSettings.showInputAttributes = !GraphEditorSettings.showInputAttributes \n                            enabled: tabBar.currentIndex === 0\n                        }\n                    }\n\n                    MenuSeparator {}\n\n                    RowLayout {\n                        CheckBox {\n                            id: defaultToggle\n                            text: \"Default\"\n                            checkable: true\n                            checked: GraphEditorSettings.showDefaultAttributes\n                            onClicked: GraphEditorSettings.showDefaultAttributes = !GraphEditorSettings.showDefaultAttributes \n                            enabled: tabBar.currentIndex === 0\n                        }\n                        CheckBox {\n                            id: modifiedToggle\n                            text: \"Modified\"\n                            checkable: true\n                            checked: GraphEditorSettings.showModifiedAttributes\n                            onClicked: GraphEditorSettings.showModifiedAttributes = !GraphEditorSettings.showModifiedAttributes \n                            enabled: tabBar.currentIndex === 0\n                        }\n                    }\n\n                    MenuSeparator {}\n\n                    RowLayout {\n                        CheckBox {\n                            id: linkToggle\n                            text: \"Link\"\n                            checkable: true\n                            checked: GraphEditorSettings.showLinkAttributes\n                            onClicked: GraphEditorSettings.showLinkAttributes = !GraphEditorSettings.showLinkAttributes \n                            enabled: tabBar.currentIndex === 0\n                        }\n                        CheckBox {\n                            id: notLinkToggle\n                            text: \"Not Link\"\n                            checkable: true\n                            checked: GraphEditorSettings.showNotLinkAttributes\n                            onClicked: GraphEditorSettings.showNotLinkAttributes = !GraphEditorSettings.showNotLinkAttributes \n                            enabled: tabBar.currentIndex === 0\n                        }\n                    }\n\n                    MenuSeparator {}\n\n                    CheckBox {\n                        id: advancedToggle\n                        text: \"Advanced\"\n                        MaterialLabel {\n                            anchors.right: parent.right; anchors.rightMargin: parent.padding;\n                            text: MaterialIcons.build\n                            anchors.verticalCenter: parent.verticalCenter\n                            font.pointSize: 8\n                        }\n                        checkable: true\n                        checked: GraphEditorSettings.showAdvancedAttributes\n                        onClicked: GraphEditorSettings.showAdvancedAttributes = !GraphEditorSettings.showAdvancedAttributes\n                    }\n                }\n                MenuItem {\n                    text: \"Open Cache Folder\"\n                    enabled: root.node !== null\n                    onClicked: Qt.openUrlExternally(Filepath.stringToUrl(root.node.internalFolder))\n                }\n\n                MenuSeparator {}\n\n                MenuItem {\n                    enabled: root.node !== null\n                    text: \"Clear Pending Status\"\n                    onClicked: {\n                        node.clearSubmittedChunks()\n                        timer.stop()\n                    }\n                }\n            }\n        }\n    }\n\n    ColumnLayout {\n        anchors.fill: parent\n\n        // CompatibilityBadge banner for CompatibilityNode\n        Loader {\n            active: root.isCompatibilityNode\n            Layout.fillWidth: true\n            visible: active  // For layout update\n\n            sourceComponent: CompatibilityBadge {\n                canUpgrade: root.node.canUpgrade\n                issueDetails: root.node.issueDetails\n                onUpgradeRequest: root.upgradeRequest()\n                sourceComponent: bannerDelegate\n            }\n        }\n\n        Loader {\n            Layout.fillHeight: true\n            Layout.fillWidth: true\n            sourceComponent: root.node ? editor_component : placeholder_component\n\n            Component {\n                id: placeholder_component\n\n                Item {\n                    Column {\n                        anchors.centerIn: parent\n                        MaterialLabel {\n                            text: MaterialIcons.select_all\n                            font.pointSize: 34\n                            color: Qt.lighter(palette.mid, 1.2)\n                            anchors.horizontalCenter: parent.horizontalCenter\n                        }\n                        Label {\n                            color: Qt.lighter(palette.mid, 1.2)\n                            text: \"Select a Node to access its Details\"\n                        }\n                    }\n                }\n            }\n\n            Component {\n                id: editor_component\n\n                MSplitView {\n                    anchors.fill: parent\n\n                    // The list of chunks\n                    ChunksListView {\n                        id: chunksLV\n                        enabled: root.node ? root.node.chunksCreated : false\n                        chunks: root.node ? root.node.chunks : null\n                        visible: enabled && (tabBar.currentIndex >= 1 && tabBar.currentIndex <= 3)\n                        SplitView.preferredWidth: 55\n                        SplitView.minimumWidth: 20\n                    }\n\n                    StackLayout {\n                        SplitView.fillWidth: true\n\n                        currentIndex: tabBar.currentIndex\n\n                        // First tab\n                        MSplitView {\n                            orientation: Qt.Vertical\n\n                            // Node shape editor\n                            Loader {\n                                id: shapeEditorLoader\n                                active: _currentScene ? \n                                    (_currentScene.selectedNode ? _currentScene.selectedNode.hasDisplayableShape : false) : false\n                                sourceComponent: ShapeEditor {\n                                    model: root.node.attributes\n                                    filterText: searchBar.text\n                                }\n                                SplitView.preferredHeight: active ? 200 : 0\n                                SplitView.minimumHeight: active ? 100 : 0\n                                SplitView.maximumHeight: active ? 400 : 0\n                            }\n\n                            // Node attribute editor\n                            AttributeEditor {\n                                id: inOutAttr\n                                objectsHideable: true\n                                Layout.fillHeight: true\n                                Layout.fillWidth: true\n                                SplitView.minimumHeight: 100\n                                model: root.node.attributes\n                                readOnly: root.readOnly || root.isCompatibilityNode\n                                onAttributeDoubleClicked: function(mouse, attribute) { root.attributeDoubleClicked(mouse, attribute) }\n                                onUpgradeRequest: root.upgradeRequest()\n                                onShowInViewer: function (attribute) {root.showAttributeInViewer(attribute)}\n                                filterText: searchBar.text\n\n                                onInAttributeClicked: function(srcItem, mouse, inAttributes) {\n                                    root.inAttributeClicked(srcItem, mouse, inAttributes)\n                                }\n                                onOutAttributeClicked: function(srcItem, mouse, outAttributes) {\n                                    root.outAttributeClicked(srcItem, mouse, outAttributes)\n                                }\n                            }\n                        }\n\n                        Loader {\n                            active: (tabBar.currentIndex === 1)\n                            Layout.fillHeight: true\n                            Layout.fillWidth: true\n                            sourceComponent: NodeLog {\n                                // anchors.fill: parent\n                                Layout.fillHeight: true\n                                Layout.fillWidth: true\n                                width: parent.width\n                                height: parent.height\n                                id: nodeLog\n                                node: root.node\n                                currentChunkIndex: chunksLV.currentIndex\n                                currentChunk: chunksLV.currentChunk\n                            }\n                        }\n\n                        Loader {\n                            active: (tabBar.currentIndex === 2)\n                            Layout.fillHeight: true\n                            Layout.fillWidth: true\n                            sourceComponent: NodeStatistics {\n                                id: nodeStatistics\n\n                                Layout.fillHeight: true\n                                Layout.fillWidth: true\n                                node: root.node\n                                currentChunkIndex: chunksLV.currentIndex\n                                currentChunk: chunksLV.currentChunk\n                            }\n                        }\n\n                        Loader {\n                            active: (tabBar.currentIndex === 3)\n                            Layout.fillHeight: true\n                            Layout.fillWidth: true\n                            sourceComponent: NodeStatus {\n                                id: nodeStatus\n\n                                Layout.fillHeight: true\n                                Layout.fillWidth: true\n                                node: root.node\n                                currentChunkIndex: chunksLV.currentIndex\n                                currentChunk: chunksLV.currentChunk\n                            }\n                        }\n\n                        Loader {\n                            active: (tabBar.currentIndex === 4)\n                            Layout.fillHeight: true\n                            Layout.fillWidth: true\n                            sourceComponent: NodeFileBrowser {\n                                id: nodeFileBrowser\n\n                                Layout.fillHeight: true\n                                Layout.fillWidth: true\n                                node: root.node\n                            }\n                        }\n\n                        NodeDocumentation {\n                            id: nodeDocumentation\n\n                            Layout.fillHeight: true\n                            Layout.fillWidth: true\n                            node: root.node\n                        }\n\n                        AttributeEditor {\n                            id: nodeInternalAttr\n                            objectsHideable: false\n                            Layout.fillHeight: true\n                            Layout.fillWidth: true\n                            model: root.node.internalAttributes\n                            readOnly: root.readOnly || root.isCompatibilityNode\n                            onAttributeDoubleClicked: function(mouse, attribute) { root.attributeDoubleClicked(mouse, attribute) }\n                            onUpgradeRequest: root.upgradeRequest()\n                            filterText: searchBar.text\n\n                            onInAttributeClicked: function(srcItem, mouse, inAttributes) {\n                                root.inAttributeClicked(srcItem, mouse, inAttributes)\n                            }\n\n                            onOutAttributeClicked: function(srcItem, mouse, outAttributes) {\n                                root.outAttributeClicked(srcItem, mouse, outAttributes)\n                            }\n                        }\n                    }\n                }\n            }\n        }\n\n        TabBar {\n            id: tabBar\n            visible: root.node !== null\n\n            property bool isComputableType: root.node !== null && root.node.isComputableType\n            property bool isBackdropNode: root.node !== null && root.node.isBackdropNode\n\n            // The indices of the tab bar which can be shown for incomputable nodes\n            readonly property var nonComputableTabIndices: [0, 5, 6]\n\n            Layout.fillWidth: true\n            width: childrenRect.width\n            position: TabBar.Footer\n            currentIndex: 0\n            TabButton {\n                text: \"Attributes\"\n                visible: !tabBar.isBackdropNode\n                width: {\n                    if (!visible)\n                        return 0\n                    else {\n                        if (tabBar.isComputableType)\n                            return tabBar.width / tabBar.count\n                        else {\n                            return tabBar.width / tabBar.nonComputableTabIndices.length\n                        }\n                    }\n                }\n                padding: 4\n                leftPadding: 8\n                rightPadding: leftPadding\n            }\n            TabButton {\n                visible: tabBar.isComputableType\n                width: !visible ? 0 : tabBar.width / tabBar.count\n                text: \"Log\"\n                leftPadding: 8\n                rightPadding: leftPadding\n            }\n            TabButton {\n                visible: tabBar.isComputableType\n                width: !visible ? 0 : tabBar.width / tabBar.count\n                text: \"Statistics\"\n                leftPadding: 8\n                rightPadding: leftPadding\n            }\n            TabButton {\n                visible: tabBar.isComputableType\n                width: !visible ? 0 : tabBar.width / tabBar.count\n                text: \"Status\"\n                leftPadding: 8\n                rightPadding: leftPadding\n            }\n            TabButton {\n                visible: tabBar.isComputableType\n                width: !visible ? 0 : tabBar.width / tabBar.count\n                text: \"Files\"\n                leftPadding: 8\n                rightPadding: leftPadding\n            }\n            TabButton {\n                text: \"Documentation\"\n                leftPadding: 8\n                rightPadding: leftPadding\n            }\n            TabButton {\n                text: \"Notes\"\n                padding: 4\n                leftPadding: 8\n                rightPadding: leftPadding\n            }\n\n            onVisibleChanged: {\n                // If we have a node selected and the node is not Computable\n                // Reset the currentIndex to 0, if the current index is not allowed for an incomputable node\n                if ((root.node && !root.node.isComputableType) && (nonComputableTabIndices.indexOf(tabBar.currentIndex) === -1)) {\n                    if (root.node.isBackdropNode) {\n                        // Backdrop nodes can only show the Documentation & Notes tabs\n                        tabBar.currentIndex = 5 // Documentation tab\n                    } else {\n                        tabBar.currentIndex = 0\n                    }\n                }\n            }\n        }\n    }\n}\n"
  },
  {
    "path": "meshroom/ui/qml/GraphEditor/NodeFileBrowser.qml",
    "content": "import QtQuick\nimport QtQuick.Controls\nimport QtQuick.Layouts\nimport Qt.labs.folderlistmodel\n\nimport Controls 1.0\nimport MaterialIcons 2.2\nimport Utils 1.0\n\n/**\n * NodeFileBrowser displays the cache folder of a Node as a navigable file browser.\n */\nFocusScope {\n    id: root\n\n    property variant node: null\n\n    // The root folder URL (node's internal cache folder)\n    readonly property url rootFolderUrl: node ? Filepath.stringToUrl(node.internalFolder) : \"\"\n    // Currently displayed folder URL\n    property url currentFolder: rootFolderUrl\n\n    // Reset to root folder when node changes\n    onRootFolderUrlChanged: {\n        root.currentFolder = root.rootFolderUrl\n    }\n\n    // Height of a normal (non-hidden) delegate item\n    readonly property int itemHeight: 24\n    readonly property bool isValidFolder: Filepath.exists(root.currentFolder)\n\n    /**\n     * Returns true if the given file name is a Meshroom-internal file that should be hidden,\n     * i.e. nodeStatus, chunk log/statistics/status files (e.g. 0.log, 0.statistics, 0.status).\n     */\n    function isInternalFile(name) {\n        return name === \"nodeStatus\"\n            || name.endsWith(\".log\")\n            || name.endsWith(\".statistics\")\n            || name.endsWith(\".status\")\n    }\n\n    SystemPalette { id: activePalette }\n\n    FolderListModel {\n        id: folderModel\n        folder: root.currentFolder\n        showFiles: true\n        showDirs: true\n        showDirsFirst: true\n        showHidden: false\n        sortField: FolderListModel.Name\n        nameFilters: [\"*\"]\n    }\n\n    ColumnLayout {\n        anchors.fill: parent\n        spacing: 0\n\n        // Toolbar: navigate up button, current path label, open-in-OS button\n        ToolBar {\n            Layout.fillWidth: true\n\n            RowLayout {\n                anchors.fill: parent\n                spacing: 2\n\n                // Navigate up button\n                MaterialToolButton {\n                    text: MaterialIcons.arrow_upward\n                    font.pointSize: 11\n                    padding: 4\n                    enabled: root.currentFolder.toString() !== root.rootFolderUrl.toString()\n                    ToolTip.text: \"Go to parent folder\"\n                    ToolTip.visible: hovered\n                    onClicked: {\n                        root.currentFolder = Filepath.stringToUrl(Filepath.dirname(Filepath.urlToString(root.currentFolder)))\n                    }\n                }\n\n                // Current folder path label\n                Label {\n                    id: pathLabel\n                    Layout.fillWidth: true\n                    elide: Text.ElideLeft\n                    text: root.node ? Filepath.urlToString(root.currentFolder) : \"\"\n                    ToolTip.text: text\n                    ToolTip.visible: hovered && truncated\n                    font.pointSize: 8\n                    verticalAlignment: Text.AlignVCenter\n                }\n\n                // Open current folder in OS file manager\n                MaterialToolButton {\n                    text: MaterialIcons.folder_open\n                    font.pointSize: 11\n                    padding: 4\n                    enabled: root.node !== null\n                    ToolTip.text: \"Open folder in file manager\"\n                    ToolTip.visible: hovered\n                    onClicked: Qt.openUrlExternally(root.currentFolder)\n                }\n            }\n        }\n\n        // File list\n        ListView {\n            id: fileListView\n            Layout.fillWidth: true\n            Layout.fillHeight: true\n            clip: true\n            focus: true\n            // When the folder does not exist, the FolderModel has a fallback to a default folder.\n            // We disable the model to avoid this problematic behavior.\n            model: isValidFolder ? folderModel : null\n            keyNavigationEnabled: true\n            highlightFollowsCurrentItem: true\n\n            ScrollBar.vertical: ScrollBar { policy: ScrollBar.AsNeeded }\n\n            // Placeholder when folder is empty, does not exist, or contains only internal files\n            Label {\n                anchors.centerIn: parent\n                visible: root.node !== null && fileListView.contentHeight === 0\n                color: Qt.lighter(activePalette.mid, 1.2)\n                text: isValidFolder ? \"Empty folder\" : \"Folder does not exist\"\n            }\n\n            delegate: ItemDelegate {\n                id: delegateItem\n                width: fileListView.width\n                // Hide Meshroom-internal files by collapsing their height\n                height: root.isInternalFile(fileName) ? 0 : root.itemHeight\n                visible: height > 0\n                padding: 0\n                leftPadding: 6\n\n                // fileIsDir is a FolderListModel role available in the delegate context\n                readonly property bool isDir: fileIsDir\n                readonly property string itemFilePath: filePath\n\n                RowLayout {\n                    anchors.fill: parent\n                    anchors.leftMargin: 6\n                    spacing: 6\n\n                    // File/folder icon\n                    MaterialLabel {\n                        text: delegateItem.isDir ? MaterialIcons.folder : MaterialIcons.insert_drive_file\n                        color: delegateItem.isDir ? \"#e8a000\" : activePalette.text\n                        font.pointSize: 10\n                        Layout.alignment: Qt.AlignVCenter\n                    }\n\n                    // File/folder name\n                    Label {\n                        Layout.fillWidth: true\n                        // fileName is a FolderListModel role available in the delegate context\n                        text: fileName\n                        elide: Text.ElideRight\n                        font.pointSize: 8\n                        verticalAlignment: Text.AlignVCenter\n                    }\n\n                    // File size (only for files, fileSize role from FolderListModel)\n                    Label {\n                        visible: !delegateItem.isDir\n                        text: {\n                            if (fileSize < 0)\n                                return \"\"\n                            if (fileSize < 1024)\n                                return fileSize + \" B\"\n                            if (fileSize < 1024 * 1024)\n                                return (fileSize / 1024).toFixed(1) + \" KB\"\n                            if (fileSize < 1024 * 1024 * 1024)\n                                return (fileSize / (1024 * 1024)).toFixed(1) + \" MB\"\n                            return (fileSize / (1024 * 1024 * 1024)).toFixed(2) + \" GB\"\n                        }\n                        color: activePalette.mid\n                        font.pointSize: 7\n                        rightPadding: 8\n                        verticalAlignment: Text.AlignVCenter\n                    }\n                }\n\n                highlighted: fileListView.currentIndex === index\n\n                onDoubleClicked: {\n                    if (delegateItem.isDir) {\n                        // fileURL is a FolderListModel role providing the URL\n                        root.currentFolder = fileURL\n                    } else {\n                        Qt.openUrlExternally(fileURL)\n                    }\n                }\n            }\n        }\n    }\n}\n"
  },
  {
    "path": "meshroom/ui/qml/GraphEditor/NodeLog.qml",
    "content": "import QtQuick\nimport QtQuick.Controls\nimport QtQuick.Layouts\n\nimport Controls 1.0\n\n/**\n * NodeLog displays the log file of Node's chunks (NodeChunks).\n *\n * To ease monitoring, it provides periodic auto-reload of the opened file\n * if the related NodeChunk is being computed.\n */\n\nFocusScope {\n    id: root\n    property variant node\n    property int currentChunkIndex\n    property variant currentChunk\n\n    Layout.fillWidth: true\n    Layout.fillHeight: true\n\n    SystemPalette { id: activePalette }\n\n    Loader {\n        id: componentLoader\n        clip: true\n        anchors.fill: parent\n\n        property string currentFile: (root.currentChunkIndex >= 0 && root.currentChunk) ? root.currentChunk[\"logFile\"] : \"\"\n        property url sourceFile: Filepath.stringToUrl(currentFile)\n\n        sourceComponent: textFileViewerComponent\n    }\n\n    Component {\n        id: textFileViewerComponent\n\n        TextFileViewer {\n            id: textFileViewer\n            anchors.fill: parent\n            source: componentLoader.sourceFile\n            autoReload: root.currentChunk !== undefined && root.currentChunk.statusName === \"RUNNING\"\n        }\n    }\n}\n"
  },
  {
    "path": "meshroom/ui/qml/GraphEditor/NodeStatistics.qml",
    "content": "import QtQuick\nimport QtQuick.Controls\nimport QtQuick.Layouts\n\nimport Controls 1.0\nimport Utils 1.0\n\n/**\n * NodeStatistics displays statistics data of Node's chunks (NodeChunks).\n *\n * To ease monitoring, it provides periodic auto-reload of the opened file\n * if the related NodeChunk is being computed.\n */\n\nFocusScope {\n    id: root\n\n    property variant node\n    property variant currentChunkIndex\n    property variant currentChunk\n\n    SystemPalette { id: activePalette }\n\n    Loader {\n        id: componentLoader\n        clip: true\n        anchors.fill: parent\n        property string currentFile: currentChunk ? currentChunk[\"statisticsFile\"] : \"\"\n        property url sourceFile: Filepath.stringToUrl(currentFile)\n\n        sourceComponent: chunksLV.chunksSummary ? statViewerComponent : chunkStatViewerComponent\n    }\n\n    Component {\n        id: chunkStatViewerComponent\n        StatViewer {\n            id: statViewer\n            anchors.fill: parent\n            source: componentLoader.sourceFile\n        }\n    }\n\n    Component {\n        id: statViewerComponent\n\n        Column {\n            spacing: 2\n            KeyValue {\n                key: \"Time\"\n                property real time: node.elapsedTime\n                value: time > 0.0 ? Format.sec2timecode(time) : \"-\"\n            }\n            KeyValue {\n                key: \"Cumulated Time\"\n                property real time: node.recursiveElapsedTime\n                value: time > 0.0 ? Format.sec2timecode(time) : \"-\"\n            }\n        }\n    }\n}\n"
  },
  {
    "path": "meshroom/ui/qml/GraphEditor/NodeStatus.qml",
    "content": "import QtQuick\nimport QtQuick.Controls\nimport QtQuick.Layouts\n\n/**\n * NodeStatus displays the status-related information of Node's chunks (NodeChunks)\n *\n * To ease monitoring, it provides periodic auto-reload of the opened file\n * if the related NodeChunk is being computed.\n */\n\nFocusScope {\n    id: root\n    property variant node\n    property variant currentChunkIndex\n    property variant currentChunk\n    property bool isChunkValid: (root.currentChunkIndex >= 0 && root.currentChunk !== undefined)\n\n    SystemPalette { id: activePalette }\n\n    Loader {\n        id: componentLoader\n        clip: true\n        anchors.fill: parent\n\n        property string currentFile: root.isChunkValid ? root.currentChunk[\"statusFile\"] : \"\"\n        property url sourceFile: Filepath.stringToUrl(currentFile)\n\n        sourceComponent: statViewerComponent\n    }\n\n    Component {\n        id: statViewerComponent\n        Item {\n            id: statusViewer\n            property url source: componentLoader.sourceFile\n            property var lastModified: undefined\n            property variant chunkStatus: root.isChunkValid ? root.currentChunk.status : undefined\n\n            onChunkStatusChanged: {\n                statusListModel.readSourceFile()\n            }\n            onSourceChanged: {\n                statusListModel.readSourceFile()\n            }\n\n            ListModel {\n                id: statusListModel\n\n                function readSourceFile() {\n                    // Make sure we are trying to load a statistics file\n                    if (!Filepath.urlToString(source).endsWith(\"status\"))\n                        return\n\n                    var xhr = new XMLHttpRequest\n                    xhr.open(\"GET\", source)\n\n                    xhr.onreadystatechange = function() {\n                        if (xhr.readyState === XMLHttpRequest.DONE && xhr.status === 200) {\n                            if (lastModified === undefined || lastModified !== xhr.getResponseHeader('Last-Modified')) {\n                                lastModified = xhr.getResponseHeader('Last-Modified')\n                                try {\n                                    var jsonObject = JSON.parse(xhr.responseText)\n\n                                    var entries = []\n                                    // Prepare data to populate the ListModel from the input json object\n                                    for (var key in jsonObject) {\n                                        var entry = {}\n                                        entry[\"key\"] = key\n                                        entry[\"value\"] = String(jsonObject[key])\n                                        entries.push(entry)\n                                    }\n                                    // Reset the model with prepared data (limit to one update event)\n                                    statusListModel.clear()\n                                    statusListModel.append(entries)\n                                } catch(exc) {\n                                    lastModified = undefined\n                                    statusListModel.clear()\n                                }\n                            }\n                        } else {\n                            lastModified = undefined\n                            statusListModel.clear()\n                        }\n                    }\n                    xhr.send()\n                }\n            }\n\n            ListView {\n                id: statusListView\n                anchors.fill: parent\n                spacing: 3\n                model: statusListModel\n\n                delegate: Rectangle {\n                    color: activePalette.window\n                    width: statusListView.width\n                    height: childrenRect.height\n                    RowLayout {\n                        width: parent.width\n                        Rectangle {\n                            id: statusKey\n                            anchors.margins: 2\n                            color: Qt.darker(activePalette.window, 1.1)\n                            Layout.preferredWidth: sizeHandle.x\n                            Layout.minimumWidth: 10.0 * Qt.application.font.pixelSize\n                            Layout.maximumWidth: 15.0 * Qt.application.font.pixelSize\n                            Layout.fillWidth: false\n                            Layout.fillHeight: true\n                            Label {\n                                text: key\n                                anchors.fill: parent\n                                anchors.top: parent.top\n                                topPadding: 4\n                                leftPadding: 6\n                                verticalAlignment: TextEdit.AlignTop\n                                elide: Text.ElideRight\n                            }\n                        }\n                        TextArea {\n                            id: statusValue\n                            text: value\n                            anchors.margins: 2\n                            Layout.fillWidth: true\n                            wrapMode: Label.WrapAtWordBoundaryOrAnywhere\n                            textFormat: TextEdit.PlainText\n\n                            readOnly: true\n                            selectByMouse: true\n                            background: Rectangle { anchors.fill: parent; color: Qt.darker(activePalette.window, 1.05) }\n                        }\n                    }\n                }\n            }\n\n            // Categories resize handle\n            Rectangle {\n                id: sizeHandle\n                height: parent.contentHeight\n                width: 1\n                x: parent.width * 0.2\n                MouseArea {\n                    anchors.fill: parent\n                    anchors.margins: -4\n                    cursorShape: Qt.SizeHorCursor\n                    drag {\n                        target: parent\n                        axis: Drag.XAxis\n                        threshold: 0\n                        minimumX: statusListView.width * 0.2\n                        maximumX: statusListView.width * 0.8\n                    }\n                }\n            }\n        }\n    }\n}\n"
  },
  {
    "path": "meshroom/ui/qml/GraphEditor/ScriptEditor.qml",
    "content": "import QtQuick\nimport QtQuick.Controls\nimport QtQuick.Dialogs\nimport QtQuick.Layouts\n\nimport Controls 1.0\nimport MaterialIcons 2.2\nimport Utils 1.0\n\nimport Qt.labs.platform as Platform\n\nimport ScriptEditor 1.0\n\nItem {\n    id: root\n\n    // Defines the parent or the root Application of which this script editor is a part of\n    property var rootApplication: undefined;\n\n    Component {\n        id: clearConfirmationDialog\n\n        MessageDialog {\n            title: \"Clear history\"\n\n            preset: \"Warning\"\n            text: \"This will clear all history of executed scripts.\"\n            helperText: \"Are you sure you would like to continue?.\"\n\n            standardButtons: Dialog.Ok | Dialog.Cancel\n            onClosed: destroy()\n        }\n    }\n\n    function replace(text, string, replacement) {\n        /**\n         * Replaces all occurrences of the string in the text\n         * @param text - overall text\n         * @param string - the string to be replaced in the text\n         * @param replacement - the replacement of the string\n         */\n        // Split with the string\n        let lines = text.split(string)\n        // Return the overall text joined with the replacement\n        return lines.join(replacement)\n    }\n\n    function formatInput(text) {\n        /**\n         * Formats the text to be displayed as the input script executed\n         */\n\n        // Replace the text to be RichText Supportive\n        return \"<font color=#868686>> Input:<br>\" + replace(text, \"\\n\", \"<br>\") + \"</font><br>\"\n    }\n\n    function formatOutput(text) {\n        /**\n         * Formats the text to be displayed as the result of the script executed\n         */\n\n        // Replace the text to be RichText Supportive\n        return \"<font color=#49a1f3>> Result:<br>\" + replace(text, \"\\n\", \"<br>\") + \"</font><br>\"\n    }\n\n    function clearHistory() {\n        /**\n         * Clears all of the executed history from the script editor\n         */\n        ScriptEditorManager.clearHistory()\n        input.clear()\n        output.clear()\n    }\n\n    function processScript(text = \"\") {\n        // Use either the provided/selected or the entire script\n        text = text || input.text\n\n        // Execute the process and fetch back the return for it\n        var ret = ScriptEditorManager.process(text)\n\n        // Append the input script and the output result to the output console\n        output.append(formatInput(text) + formatOutput(ret))\n\n        // Save the entire script after executing the commands\n        ScriptEditorManager.saveScript(input.text)\n    }\n\n    function loadScript(fileUrl) {\n        var request = new XMLHttpRequest()\n        request.open(\"GET\", fileUrl, false)\n        request.send(null)\n        return request.responseText\n    }\n\n    function saveScript(fileUrl, content) {\n        var request = new XMLHttpRequest()\n        request.open(\"PUT\", fileUrl, false)\n        request.send(content)\n        return request.status\n    }\n\n    implicitWidth: 500\n    implicitHeight: 500\n\n    Platform.FileDialog {\n        id: loadScriptDialog\n        title: \"Load Script\"\n        nameFilters: [\"Python Script (*.py)\"]\n        onAccepted: {\n            input.clear()\n            input.text = loadScript(currentFile)\n        }\n    }\n\n    Platform.FileDialog {\n        id: saveScriptDialog\n        title: \"Save script\"\n        nameFilters: [\"Python Script (*.py)\"]\n        fileMode: Platform.FileDialog.SaveFile\n\n        signal closed(var result)\n\n        onAccepted: {\n            if (Filepath.extension(currentFile) != \".py\")\n                currentFile = currentFile + \".py\"\n            var ret = saveScript(currentFile, input.text)\n            if (ret)\n                closed(Platform.Dialog.Accepted)\n            else\n                closed(Platform.Dialog.Rejected)\n        }\n\n        onRejected: closed(Platform.Dialog.Rejected)\n    }\n\n    ColumnLayout {\n        anchors.fill: parent\n\n        RowLayout {\n            Layout.alignment: Qt.AlignVCenter | Qt.AlignHCenter\n\n            MaterialToolButton {\n                font.pointSize: 13\n                text: MaterialIcons.file_open\n                ToolTip.text: \"Load Script\"\n\n                onClicked: {\n                    loadScriptDialog.open()\n                }\n            }\n\n            MaterialToolButton {\n                font.pointSize: 13\n                text: MaterialIcons.save\n                ToolTip.text: \"Save Script\"\n\n                onClicked: {\n                    saveScriptDialog.open()\n                }\n            }\n\n            MaterialToolButton {\n                font.pointSize: 13\n                text: MaterialIcons.history\n                ToolTip.text: \"Get Previous Script\"\n\n                enabled: ScriptEditorManager.hasPreviousScript;\n\n                onClicked: {\n                    var ret = ScriptEditorManager.getPreviousScript()\n\n                    if (ret != \"\") {\n                        input.clear()\n                        input.text = ret\n                    }\n                }\n            }\n\n            MaterialToolButton {\n                font.pointSize: 13\n                text: MaterialIcons.update\n                ToolTip.text: \"Get Next Script\"\n\n                enabled: ScriptEditorManager.hasNextScript;\n\n                onClicked: {\n                    var ret = ScriptEditorManager.getNextScript()\n\n                    if (ret != \"\") {\n                        input.clear()\n                        input.text = ret\n                    }\n                }\n            }\n\n            MaterialToolButton {\n                font.pointSize: 13\n                text: MaterialIcons.delete_sweep\n                ToolTip.text: \"Clear History\"\n\n                onClicked: {\n                    // Confirm from the user before clearing out any history\n                    const confirmationDialog = clearConfirmationDialog.createObject(rootApplication ? rootApplication : root);\n                    confirmationDialog.accepted.connect(clearHistory);\n                    confirmationDialog.open();\n                }\n            }\n\n            Item {\n                width: executeButton.width;\n            }\n            \n            MaterialToolButton {\n                id: executeButton\n                font.pointSize: 13\n                text: MaterialIcons.play_arrow\n                ToolTip.text: \"Execute Script\"\n\n                onClicked: {\n                    root.processScript()\n                }\n            }\n\n            Item {\n                Layout.fillWidth: true\n            }\n\n            MaterialToolButton {\n                font.pointSize: 13\n                text: MaterialIcons.backspace\n                ToolTip.text: \"Clear Output Window\"\n\n                onClicked: {\n                    output.clear()\n                }\n            }\n        }\n\n        MSplitView {\n            id: scriptSplitView;\n            Layout.fillHeight: true;\n            Layout.fillWidth: true;\n            orientation: Qt.Horizontal;\n\n            // Input Text Area -- Holds the input scripts to be executed\n            Rectangle {\n                id: inputArea\n                SplitView.preferredWidth: root.width / 2;\n\n                color: palette.base\n\n                ListView {\n                    id: lineNumbers\n                    property TextMetrics textMetrics: TextMetrics { text: \"9999\" }\n                    model: input.text.split(/\\n/g)\n                    anchors.left: parent.left\n                    anchors.top: parent.top\n                    anchors.bottom: parent.bottom\n                    width: lineNumbers.textMetrics.boundingRect.width\n                    clip: false\n\n                    delegate: Rectangle {\n                        width: lineNumbers.width\n                        height: lineText.height\n                        color: palette.mid\n                        Text {\n                            id: lineNumber\n                            anchors.horizontalCenter: parent.horizontalCenter\n                            text: index + 1\n                            font.bold: true\n                            color: palette.text\n                        }\n\n                        Text {\n                            id: lineText\n                            width: flickableInput.width\n                            text: modelData\n                            visible: false\n                            wrapMode: Text.WordWrap\n                        }\n                    }\n\n                    onContentYChanged: {\n                        if (!moving)\n                            return\n                        flickableInput.contentY = contentY\n                    }\n                }\n\n                Flickable {\n                    id: flickableInput\n                    width: parent.width\n                    height: parent.height\n                    contentWidth: width\n                    contentHeight: input.contentHeight;\n\n                    anchors.left: lineNumbers.right\n                    anchors.top: parent.top\n                    anchors.right: parent.right\n                    anchors.bottom: parent.bottom\n\n                    ScrollBar.vertical: MScrollBar {}\n\n                    TextArea.flickable: TextArea {\n                        id: input\n\n                        text: ScriptEditorManager.loadLastScript()\n\n                        font: lineNumbers.textMetrics.font\n                        Layout.fillHeight: true\n                        Layout.fillWidth: true\n\n                        wrapMode: Text.WordWrap\n                        selectByMouse: true\n                        padding: 0\n\n                        onPressed: {\n                            root.forceActiveFocus()\n                        }\n\n                        Keys.onPressed: function(event) {\n                            if ((event.key === Qt.Key_Enter || event.key === Qt.Key_Return) && event.modifiers === Qt.ControlModifier) {\n                                root.processScript(input.selectedText)\n                            }\n                        }\n                    }\n\n                    onContentYChanged: {\n                        if (lineNumbers.moving)\n                            return\n                        lineNumbers.contentY = contentY\n                    }\n                }\n            }\n\n            // Output Text Area -- Shows the output for the executed script(s)\n            Rectangle {\n                id: outputArea\n                Layout.fillHeight: true\n                Layout.fillWidth: true\n\n                color: palette.base\n\n                Flickable {\n                    width: parent.width\n                    height: parent.height\n                    contentWidth: width\n                    contentHeight: output.contentHeight;\n\n                    ScrollBar.vertical: MScrollBar {}\n\n                    TextArea.flickable: TextArea {\n                        id: output\n\n                        readOnly: true\n                        selectByMouse: true\n                        padding: 0\n                        Layout.fillHeight: true\n                        Layout.fillWidth: true\n                        wrapMode: Text.WordWrap\n\n                        textFormat: Text.RichText\n                    }\n                }\n            }\n\n            // Syntax Highlights for the Input Area for Python Based Syntax\n            PySyntaxHighlighter {\n                id: syntaxHighlighter\n                // The document to highlight\n                textDocument: input.textDocument\n            }\n        }\n    }\n}"
  },
  {
    "path": "meshroom/ui/qml/GraphEditor/StatViewer.qml",
    "content": "import QtCharts\nimport QtQuick\nimport QtQuick.Controls\nimport QtQuick.Layouts\n\nimport Charts 1.0\nimport MaterialIcons 2.2\nimport Utils 1.0\n\n\nItem {\n    id: root\n\n    implicitWidth: 500\n    implicitHeight: 500\n\n    /// Statistics source file\n    property url source\n\n    property var sourceModified: undefined\n    property var jsonObject\n    property real fileVersion: 0.0\n\n    property int nbReads: 1\n    property real deltaTime: 1\n\n    property int nbCores: 0\n    property int cpuFrequency: 0\n\n    property int ramTotal\n    property string ramLabel: \"RAM: \"\n\n    property int maxDisplayLength: 500\n    property int gpuTotalMemory\n    property int gpuMaxAxis: 100\n    property string gpuName\n\n    property color textColor: Colors.sysPalette.text\n\n    readonly property var colors: [\n        \"#f44336\",\n        \"#e91e63\",\n        \"#9c27b0\",\n        \"#673ab7\",\n        \"#3f51b5\",\n        \"#2196f3\",\n        \"#03a9f4\",\n        \"#00bcd4\",\n        \"#009688\",\n        \"#4caf50\",\n        \"#8bc34a\",\n        \"#cddc39\",\n        \"#ffeb3b\",\n        \"#ffc107\",\n        \"#ff9800\",\n        \"#ff5722\",\n        \"#b71c1c\",\n        \"#880E4F\",\n        \"#4A148C\",\n        \"#311B92\",\n        \"#1A237E\",\n        \"#0D47A1\",\n        \"#01579B\",\n        \"#006064\",\n        \"#004D40\",\n        \"#1B5E20\",\n        \"#33691E\",\n        \"#827717\",\n        \"#F57F17\",\n        \"#FF6F00\",\n        \"#E65100\",\n        \"#BF360C\"\n    ]\n\n    onSourceChanged: {\n        sourceModified = undefined;\n        resetCharts()\n        readSourceFile()\n    }\n\n    function getPropertyWithDefault(prop, name, defaultValue) {\n        if (prop.hasOwnProperty(name)) {\n            return prop[name]\n        }\n        return defaultValue\n    }\n\n    Timer {\n        id: reloadTimer\n        interval: root.deltaTime * 60000; running: true; repeat: false\n        onTriggered: readSourceFile()\n\n    }\n\n    function readSourceFile() {\n        // Make sure we are trying to load a statistics file\n        if (!Filepath.urlToString(source).endsWith(\"statistics\"))\n            return\n\n        var xhr = new XMLHttpRequest\n        xhr.open(\"GET\", source)\n\n        xhr.onreadystatechange = function() {\n            if (xhr.readyState === XMLHttpRequest.DONE && xhr.status == 200) {\n                if (sourceModified === undefined || sourceModified < xhr.getResponseHeader(\"Last-Modified\")) {\n                    try {\n                        root.jsonObject = JSON.parse(xhr.responseText)\n                    } catch(exc) {\n                        console.warning(\"Failed to parse statistics file: \" + source)\n                        root.jsonObject = {}\n                        return\n                    }\n                    resetCharts()\n                    sourceModified = xhr.getResponseHeader(\"Last-Modified\")\n                    root.createCharts()\n                    reloadTimer.restart()\n                }\n            }\n        }\n        xhr.send()\n    }\n\n    function resetCharts() {\n        root.fileVersion = 0.0\n        cpuLegend.clear()\n        cpuChart.removeAllSeries()\n        ramChart.removeAllSeries()\n        gpuChart.removeAllSeries()\n    }\n\n    function createCharts() {\n        root.deltaTime = getPropertyWithDefault(jsonObject, \"interval\", 30) / 60.0;\n        root.fileVersion = getPropertyWithDefault(jsonObject, \"fileVersion\", 0.0)\n        initCpuChart()\n        initRamChart()\n        initGpuChart()\n    }\n\n\n/**************************\n***         CPU         ***\n**************************/\n\n    function initCpuChart() {\n\n        var categories = []\n        var categoryCount = 0\n        var category\n        do {\n            category = jsonObject.computer.curves[\"cpuUsage.\" + categoryCount]\n            if (category !== undefined) {\n                categories.push(category)\n                categoryCount++\n            }\n        } while(category !== undefined)\n\n        var nbCores = categories.length\n        root.nbCores = nbCores\n\n        root.cpuFrequency = getPropertyWithDefault(jsonObject.computer, \"cpuFreq\", -1)\n\n        root.nbReads = categories[0].length-1\n\n        for (var j = 0; j < nbCores; j++) {\n            var lineSerie = cpuChart.createSeries(ChartView.SeriesTypeLine, \"CPU\" + j, valueCpuX, valueCpuY)\n\n            if (categories[j].length === 1) {\n                lineSerie.append(0, categories[j][0])\n                lineSerie.append(root.deltaTime, categories[j][0])\n            } else {\n                var displayLength = Math.min(maxDisplayLength, categories[j].length)\n                var step = categories[j].length / displayLength\n                for (var kk = 0; kk < displayLength; kk += step) {\n                    var k = Math.floor(kk * step)\n                    lineSerie.append(k * root.deltaTime, categories[j][k])\n                }\n            }\n            lineSerie.color = colors[j % colors.length]\n        }\n\n        var averageLine = cpuChart.createSeries(ChartView.SeriesTypeLine, \"AVERAGE\", valueCpuX, valueCpuY)\n        var average = []\n\n        var displayLengthA = Math.min(maxDisplayLength, categories[0].length)\n        var stepA = categories[0].length / displayLengthA\n        for (var l = 0; l < displayLengthA; l += step) {\n            average.push(0)\n        }\n\n        for (var m = 0; m < categories.length; m++) {\n            var displayLengthB = Math.min(maxDisplayLength, categories[m].length)\n            var stepB = categories[0].length / displayLengthB\n            for (var nn = 0; nn < displayLengthB; nn++) {\n                var n = Math.floor(nn * stepB)\n                average[nn] += categories[m][n]\n            }\n        }\n\n        for (var q = 0; q < average.length; q++) {\n            average[q] = average[q] / (categories.length)\n            averageLine.append(q * root.deltaTime * stepA, average[q])\n        }\n\n        averageLine.color = colors[colors.length - 1]\n    }\n\n    function hideOtherCpu(index) {\n        for (var i = 0; i < cpuChart.count; i++) {\n            cpuChart.series(i).visible = false\n        }\n        cpuChart.series(index).visible = true\n    }\n\n\n/**************************\n***         RAM         ***\n**************************/\n\n    function initRamChart() {\n\n        var ram = getPropertyWithDefault(jsonObject.computer.curves, \"ramUsage\", -1)\n\n        root.ramTotal = getPropertyWithDefault(jsonObject.computer, \"ramTotal\", -1)\n        root.ramLabel = \"RAM: \"\n        if (root.ramTotal <= 0) {\n            var maxRamPeak = 0\n            for (var i = 0; i < ram.length; i++) {\n                maxRamPeak = Math.max(maxRamPeak, ram[i])\n            }\n            root.ramTotal = maxRamPeak\n            root.ramLabel = \"RAM Max Peak: \"\n        }\n\n        var ramSerie = ramChart.createSeries(ChartView.SeriesTypeLine, root.ramLabel + root.ramTotal + \"GB\", valueRamX, valueRamY)\n\n        if (ram.length === 1) {\n            // Create 2 entries if we have only one input value to create a segment that can be display\n            ramSerie.append(0, ram[0])\n            ramSerie.append(root.deltaTime, ram[0])\n        } else {\n            var displayLength = Math.min(maxDisplayLength, ram.length)\n            var step = ram.length / displayLength\n            for(var ii = 0; ii < displayLength; ii++) {\n                var i = Math.floor(ii * step)\n                ramSerie.append(i * root.deltaTime, ram[i])\n            }\n        }\n        ramSerie.color = colors[10]\n    }\n\n\n/**************************\n***         GPU         ***\n**************************/\n\n    function initGpuChart() {\n        root.gpuTotalMemory = getPropertyWithDefault(jsonObject.computer, \"gpuMemoryTotal\", 0)\n        root.gpuName = getPropertyWithDefault(jsonObject.computer, \"gpuName\", \"\")\n\n        var gpuUsedMemory = getPropertyWithDefault(jsonObject.computer.curves, \"gpuMemoryUsed\", 0)\n        var gpuUsed = getPropertyWithDefault(jsonObject.computer.curves, \"gpuUsed\", 0)\n        var gpuTemperature = getPropertyWithDefault(jsonObject.computer.curves, \"gpuTemperature\", 0)\n\n        var gpuUsedSerie = gpuChart.createSeries(ChartView.SeriesTypeLine, \"GPU\", valueGpuX, valueGpuY)\n        var gpuUsedMemorySerie = gpuChart.createSeries(ChartView.SeriesTypeLine, \"Memory\", valueGpuX, valueGpuY)\n        var gpuTemperatureSerie = gpuChart.createSeries(ChartView.SeriesTypeLine, \"Temperature\", valueGpuX, valueGpuY)\n\n        var gpuMemoryRatio = root.gpuTotalMemory > 0 ? (100 / root.gpuTotalMemory) : 1\n\n        if (gpuUsedMemory.length === 1) {\n            gpuUsedSerie.append(0, gpuUsed[0])\n            gpuUsedSerie.append(1 * root.deltaTime, gpuUsed[0])\n\n            gpuUsedMemorySerie.append(0, gpuUsedMemory[0] * gpuMemoryRatio)\n            gpuUsedMemorySerie.append(1 * root.deltaTime, gpuUsedMemory[0] * gpuMemoryRatio)\n\n            gpuTemperatureSerie.append(0, gpuTemperature[0])\n            gpuTemperatureSerie.append(1 * root.deltaTime, gpuTemperature[0])\n            root.gpuMaxAxis = Math.max(gpuMaxAxis, gpuTemperature[0])\n        } else {\n            var displayLength = Math.min(maxDisplayLength, gpuUsedMemory.length)\n            var step = gpuUsedMemory.length / displayLength\n            for (var ii = 0; ii < displayLength; ii += step) {\n                var i = Math.floor(ii*step)\n                gpuUsedSerie.append(i * root.deltaTime, gpuUsed[i])\n\n                gpuUsedMemorySerie.append(i * root.deltaTime, gpuUsedMemory[i] * gpuMemoryRatio)\n\n                gpuTemperatureSerie.append(i * root.deltaTime, gpuTemperature[i])\n                root.gpuMaxAxis = Math.max(gpuMaxAxis, gpuTemperature[i])\n            }\n        }\n    }\n\n\n/**************************\n***          UI         ***\n**************************/\n\n    ScrollView {\n        height: root.height\n        width: root.width\n        ScrollBar.vertical.policy: ScrollBar.AlwaysOn\n\n        ColumnLayout {\n            width: root.width\n\n\n/**************************\n***       CPU UI        ***\n**************************/\n\n            Button {\n                id: toggleCpuBtn\n                Layout.fillWidth: true\n                text: \"Toggle CPU's\"\n                state: \"closed\"\n\n                onClicked: state === \"opened\" ? state = \"closed\" : state = \"opened\"\n\n                MaterialLabel {\n                    text: MaterialIcons.arrow_drop_down\n                    font.pointSize: 14\n                    anchors.right: parent.right\n                }\n\n                states: [\n                    State {\n                        name: \"opened\"\n                        PropertyChanges { target: cpuBtnContainer; visible: true }\n                        PropertyChanges { target: toggleCpuBtn; down: true }\n                    },\n                    State {\n                        name: \"closed\"\n                        PropertyChanges { target: cpuBtnContainer; visible: false }\n                        PropertyChanges { target: toggleCpuBtn; down: false }\n                    }\n                ]\n            }\n\n            Item {\n                id: cpuBtnContainer\n\n                Layout.fillWidth: true\n                implicitHeight: childrenRect.height\n                Layout.leftMargin: 25\n\n                RowLayout {\n                    width: parent.width\n                    anchors.horizontalCenter: parent.horizontalCenter\n\n                    ChartViewCheckBox {\n                        id: allCPU\n                        text: \"ALL\"\n                        color: textColor\n                        checkState: cpuLegend.buttonGroup.checkState\n                        leftPadding: 0\n                        onClicked: {\n                            var _checked = checked;\n                            for (var i = 0; i < cpuChart.count; ++i) {\n                                cpuChart.series(i).visible = _checked\n                            }\n                        }\n                    }\n\n                    ChartViewLegend {\n                        id: cpuLegend\n                        Layout.fillWidth: true\n                        Layout.fillHeight: true\n                        chartView: cpuChart\n                    }\n                }\n            }\n\n            InteractiveChartView {\n                id: cpuChart\n\n                Layout.fillWidth: true\n                Layout.preferredHeight: width / 2\n                margins.top: 0\n                margins.bottom: 0\n                antialiasing: true\n\n                legend.visible: false\n                theme: ChartView.ChartThemeLight\n                backgroundColor: \"transparent\"\n                plotAreaColor: \"transparent\"\n                titleColor: textColor\n\n                visible: (root.fileVersion > 0.0)  // Only visible if we have valid information\n                title: \"CPU: \" + root.nbCores + \" cores, \" + root.cpuFrequency + \"MHz\"\n\n                ValueAxis {\n                    id: valueCpuY\n                    min: 0\n                    max: 100\n                    titleText: \"<span style='color: \" + textColor + \"'>%</span>\"\n                    color: textColor\n                    gridLineColor: textColor\n                    minorGridLineColor: textColor\n                    shadesColor: textColor\n                    shadesBorderColor: textColor\n                    labelsColor: textColor\n                }\n\n                ValueAxis {\n                    id: valueCpuX\n                    min: 0\n                    max: root.deltaTime * Math.max(1, root.nbReads)\n                    titleText: \"<span style='color: \" + textColor + \"'>Minutes</span>\"\n                    color: textColor\n                    gridLineColor: textColor\n                    minorGridLineColor: textColor\n                    shadesColor: textColor\n                    shadesBorderColor: textColor\n                    labelsColor: textColor\n                }\n            }\n\n/**************************\n***       RAM UI        ***\n**************************/\n\n            InteractiveChartView {\n                id: ramChart\n                margins.top: 0\n                margins.bottom: 0\n                Layout.fillWidth: true\n                Layout.preferredHeight: width / 2\n                antialiasing: true\n                legend.color: textColor\n                legend.labelColor: textColor\n                legend.visible: false\n                theme: ChartView.ChartThemeLight\n                backgroundColor: \"transparent\"\n                plotAreaColor: \"transparent\"\n                titleColor: textColor\n\n                visible: (root.fileVersion > 0.0)  // Only visible if we have valid information\n                title: root.ramLabel + root.ramTotal + \"GB\"\n\n                ValueAxis {\n                    id: valueRamY\n                    min: 0\n                    max: 100\n                    titleText: \"<span style='color: \" + textColor + \"'>%</span>\"\n                    color: textColor\n                    gridLineColor: textColor\n                    minorGridLineColor: textColor\n                    shadesColor: textColor\n                    shadesBorderColor: textColor\n                    labelsColor: textColor\n                }\n\n                ValueAxis {\n                    id: valueRamX\n                    min: 0\n                    max: root.deltaTime * Math.max(1, root.nbReads)\n                    titleText: \"<span style='color: \" + textColor + \"'>Minutes</span>\"\n                    color: textColor\n                    gridLineColor: textColor\n                    minorGridLineColor: textColor\n                    shadesColor: textColor\n                    shadesBorderColor: textColor\n                    labelsColor: textColor\n                }\n            }\n\n/**************************\n***       GPU UI        ***\n**************************/\n\n            InteractiveChartView {\n                id: gpuChart\n\n                Layout.fillWidth: true\n                Layout.preferredHeight: width/2\n                margins.top: 0\n                margins.bottom: 0\n                antialiasing: true\n                legend.color: textColor\n                legend.labelColor: textColor\n                theme: ChartView.ChartThemeLight\n                backgroundColor: \"transparent\"\n                plotAreaColor: \"transparent\"\n                titleColor: textColor\n\n                visible: (root.fileVersion >= 2.0)  // No GPU information was collected before stats 2.0 fileVersion\n                title: (root.gpuName || root.gpuTotalMemory) ? (\"GPU: \" + root.gpuName + \", \" + root.gpuTotalMemory + \"MB\") : \"No GPU\"\n\n                ValueAxis {\n                    id: valueGpuY\n                    min: 0\n                    max: root.gpuMaxAxis\n                    titleText: \"<span style='color: \" + textColor + \"'>%, °C</span>\"\n                    color: textColor\n                    gridLineColor: textColor\n                    minorGridLineColor: textColor\n                    shadesColor: textColor\n                    shadesBorderColor: textColor\n                    labelsColor: textColor\n                }\n\n                ValueAxis {\n                    id: valueGpuX\n                    min: 0\n                    max: root.deltaTime * Math.max(1, root.nbReads)\n                    titleText: \"<span style='color: \" + textColor + \"'>Minutes</span>\"\n                    color: textColor\n                    gridLineColor: textColor\n                    minorGridLineColor: textColor\n                    shadesColor: textColor\n                    shadesBorderColor: textColor\n                    labelsColor: textColor\n                }\n            }\n        }\n    }\n}\n"
  },
  {
    "path": "meshroom/ui/qml/GraphEditor/TaskManager.qml",
    "content": "import QtQuick\nimport QtQuick.Controls\nimport QtQuick.Layouts\n\nimport MaterialIcons 2.2\nimport Controls 1.0\nimport Utils 1.0\n\nItem {\n    id: root\n\n    implicitWidth: 500\n    implicitHeight: 500\n\n    property var uigraph\n    property var taskManager\n\n    SystemPalette { id: activePalette }\n\n    property color textColor: Colors.sysPalette.text\n    property color bgColor: Qt.darker(Colors.sysPalette.window, 1.15)\n    property color headBgColor: Qt.darker(Colors.sysPalette.window, 1.30)\n    property color tableBorder: Colors.sysPalette.window\n    property int borderWidth: 3\n\n    // Max width for some columns\n    readonly property int maxExecWidth: 200\n\n    property var selectedChunk: null\n\n    function selectNode(node) {\n        uigraph.selectedNode = node\n    }\n\n    function selectChunk(chunk) {\n        root.selectedChunk = chunk\n        uigraph.selectedChunk = chunk\n    }\n    \n    TextMetrics {\n        id: nbMetrics\n        text: root.taskManager ? root.taskManager.nodes.count : \"0\"\n    }\n\n    TextMetrics {\n        id: statusMetrics\n        text: \"SUBMITTED\"\n    }\n\n    TextMetrics {\n        id: chunksMetrics\n        text: \"Chunks Done\"\n    }\n\n    TextMetrics {\n        id: execMetrics\n        text: \"Exec Mode\"\n    }\n\n    TextMetrics {\n        id: progressMetrics\n        text: \"Progress\"\n    }\n\n    RowLayout {\n        anchors.fill: parent\n\n        ColumnLayout {\n            Layout.alignment: Qt.AlignLeft | Qt.AlignTop\n            width: childrenRect.width\n            spacing: 8\n\n            // TODO : enable/disable buttons depending on selectedChunk\n            // TODO : Also handle case where uigraph.selectedNode and selectedNode.chunksCreated==false\n\n            // Task toolbar\n            Rectangle {\n                Layout.preferredWidth: 40\n                Layout.preferredHeight: taskColumn.height + 8\n                color: \"transparent\"\n                border.color: Colors.darkpurple\n                border.width: 2\n                radius: 8\n\n                ColumnLayout {\n                    id: taskColumn\n                    anchors.centerIn: parent\n                    spacing: 2\n\n                    MaterialToolButton {\n                        ToolTip.text: \"Stop Task\"\n                        Layout.alignment: Qt.AlignHCenter\n                        enabled: selectedChunk !== null || root.uigraph.selectedNode !== null\n                        text: MaterialIcons.stop_circle\n                        font.pointSize: 15\n                        onClicked: {\n                            if (selectedChunk !== null) {\n                                root.uigraph.stopTask(selectedChunk)\n                            } else {\n                                root.uigraph.stopNode(root.uigraph.selectedNode)\n                            }\n                        }\n                    }\n\n                    MaterialToolButton {\n                        ToolTip.text: \"Restart Task\"\n                        Layout.alignment: Qt.AlignHCenter\n                        enabled: selectedChunk !== null\n                        text: MaterialIcons.replay_circle_filled\n                        font.pointSize: 15\n                        onClicked: {\n                            uigraph.restartTask(selectedChunk)\n                        }\n                    }\n\n                    MaterialToolButton {\n                        ToolTip.text: \"Skip Task\"\n                        Layout.alignment: Qt.AlignHCenter\n                        enabled: selectedChunk !== null\n                        text: MaterialIcons.skip_next\n                        font.pointSize: 15\n                        onClicked: {\n                            uigraph.skipTask(selectedChunk)\n                        }\n                    }\n\n                    Item {\n                        Layout.preferredWidth: 40\n                        Layout.preferredHeight: 50\n                        \n                        Text {\n                            text: \"TASK\"\n                            anchors.centerIn: parent\n                            color: Colors.sysPalette.text\n                            font.pixelSize: 11\n                            font.bold: true\n                            rotation: -90\n                            transformOrigin: Item.Center\n                        }\n                    }\n                }\n            }\n            \n            // Job toolbar\n            Rectangle {\n                Layout.preferredWidth: 40\n                Layout.preferredHeight: jobColumn.height + 8\n                color: \"transparent\"\n                border.color: Colors.darkpurple\n                border.width: 2\n                radius: 8\n\n                ColumnLayout {\n                    id: jobColumn\n                    anchors.centerIn: parent\n                    spacing: 2\n\n                    MaterialToolButton {\n                        ToolTip.text: \"Pause Job\"\n                        Layout.alignment: Qt.AlignHCenter\n                        enabled: root.uigraph.selectedNode !== null\n                        text: MaterialIcons.pause_circle_filled\n                        font.pointSize: 15\n                        onClicked: {\n                            uigraph.pauseJob(uigraph.selectedNode)\n                        }\n                    }\n\n                    MaterialToolButton {\n                        ToolTip.text: \"Resume Job\"\n                        Layout.alignment: Qt.AlignHCenter\n                        enabled: root.uigraph.selectedNode !== null\n                        text: MaterialIcons.play_circle_filled\n                        font.pointSize: 15\n                        onClicked: {\n                            uigraph.resumeJob(uigraph.selectedNode)\n                        }\n                    }\n\n                    MaterialToolButton {\n                        ToolTip.text: \"Interrupt Job\"\n                        Layout.alignment: Qt.AlignHCenter\n                        enabled: root.uigraph.selectedNode !== null\n                        text: MaterialIcons.stop_circle\n                        font.pointSize: 15\n                        onClicked: {\n                            uigraph.interruptJob(uigraph.selectedNode)\n                        }\n                    }\n                    \n                    MaterialToolButton {\n                        ToolTip.text: \"Restart All Error Tasks\"\n                        Layout.alignment: Qt.AlignHCenter\n                        enabled: root.uigraph.selectedNode !== null\n                        text: MaterialIcons.replay_circle_filled\n                        font.pointSize: 15\n                        onClicked: {\n                            uigraph.restartJobErrorTasks(uigraph.selectedNode)\n                        }\n                    }\n                    \n                    Item {\n                        Layout.preferredWidth: 40\n                        Layout.preferredHeight: 40\n                        \n                        Text {\n                            text: \"JOB\"\n                            anchors.centerIn: parent\n                            color: Colors.sysPalette.text\n                            font.pixelSize: 11\n                            font.bold: true\n                            rotation: -90\n                            transformOrigin: Item.Center\n                        }\n                    }\n                }\n            }\n        }\n\n        ListView {\n            id: taskList\n            Layout.alignment: Qt.AlignLeft | Qt.AlignTop\n            Layout.fillWidth: true\n            Layout.fillHeight: true\n            ScrollBar.vertical: MScrollBar {}\n\n            model: root.taskManager ? root.taskManager.nodes : null\n            spacing: 3\n\n            headerPositioning: ListView.OverlayHeader\n\n            header: RowLayout {\n                height: 30\n                spacing: 3\n\n                width: parent.width\n\n                z: 2\n\n                Label {\n                    text: qsTr(\"Nb\")\n                    Layout.preferredWidth: nbMetrics.width + 20\n                    Layout.preferredHeight: parent.height\n                    horizontalAlignment: Label.AlignHCenter\n                    verticalAlignment: Label.AlignVCenter\n                    background: Rectangle {\n                        color: headBgColor\n                    }\n                }\n                Label {\n                    text: qsTr(\"Node\")\n                    Layout.preferredWidth: 200\n                    Layout.preferredHeight: parent.height\n                    horizontalAlignment: Label.AlignHCenter\n                    verticalAlignment: Label.AlignVCenter\n                    background: Rectangle {\n                        color: headBgColor\n                    }\n                }\n                Label {\n                    text: qsTr(\"State\")\n                    Layout.preferredWidth: statusMetrics.width + 20\n                    Layout.preferredHeight: parent.height\n                    horizontalAlignment: Label.AlignHCenter\n                    verticalAlignment: Label.AlignVCenter\n                    background: Rectangle {\n                        color: headBgColor\n                    }\n                }\n                Label {\n                    text: qsTr(\"Chunks Done\")\n                    Layout.preferredWidth: chunksMetrics.width + 20\n                    Layout.preferredHeight: parent.height\n                    horizontalAlignment: Label.AlignHCenter\n                    verticalAlignment: Label.AlignVCenter\n                    background: Rectangle {\n                        color: headBgColor\n                    }\n                }\n                Label {\n                    text: qsTr(\"Exec Mode\")\n                    Layout.preferredWidth: execMetrics.width + 60\n                    Layout.preferredHeight: parent.height\n                    horizontalAlignment: Label.AlignHCenter\n                    verticalAlignment: Label.AlignVCenter\n                    background: Rectangle {\n                        color: headBgColor\n                    }\n                }\n                Label {\n                    text: qsTr(\"Progress\")\n                    Layout.fillWidth: true\n                    Layout.minimumWidth: progressMetrics.width + 20\n                    Layout.preferredHeight: parent.height\n                    horizontalAlignment: Label.AlignHCenter\n                    verticalAlignment: Label.AlignVCenter\n                    background: Rectangle {\n                        color: headBgColor\n                    }\n                }\n            }\n\n            delegate: RowLayout {\n                width: ListView.view.width\n                height: 18\n                spacing: 3\n\n                function getNbFinishedChunks(chunks) {\n                    var nbSuccess = 0\n                    for (var i = 0; i < chunks.count; i++) {\n                        if (chunks.at(i).statusName === \"SUCCESS\") {\n                            nbSuccess += 1\n                        }\n                    }\n                    return nbSuccess\n                }\n\n                Label {\n                    text: index + 1\n                    Layout.preferredWidth: nbMetrics.width + 20\n                    Layout.preferredHeight: parent.height\n                    horizontalAlignment: Label.AlignHCenter\n                    verticalAlignment: Label.AlignVCenter\n                    color: object === uigraph.selectedNode ? Colors.sysPalette.window : Colors.sysPalette.text\n                    background: Rectangle {\n                        color: object === uigraph.selectedNode ? Colors.sysPalette.text : bgColor\n                    }\n\n                    MouseArea {\n                        anchors.fill: parent\n                        onPressed: {\n                            selectNode(object)\n                        }\n                    }\n                }\n                Label {\n                    text: object.label\n                    elide: Text.ElideRight\n                    Layout.preferredWidth: 200\n                    Layout.preferredHeight: parent.height\n                    horizontalAlignment: Label.AlignHCenter\n                    verticalAlignment: Label.AlignVCenter\n                    color: object === uigraph.selectedNode ? Colors.sysPalette.window : Colors.sysPalette.text\n                    background: Rectangle {\n                        color: object === uigraph.selectedNode ? Colors.sysPalette.text : bgColor\n                    }\n\n                    MouseArea {\n                        anchors.fill: parent\n                        acceptedButtons: Qt.LeftButton | Qt.RightButton\n                        onPressed: (mouse) => {\n                            if (mouse.button === Qt.LeftButton) {\n                                selectNode(object)\n                            } else if (mouse.button === Qt.RightButton) {\n                                contextMenu.popup()\n                            }\n                        }\n                        Menu {\n                            id: contextMenu\n                            MenuItem {\n                                text: \"Open Folder\"\n                                height: visible ? implicitHeight : 0\n                                onTriggered: Qt.openUrlExternally(Filepath.stringToUrl(object.internalFolder))\n                            }\n                        }\n                    }\n                }\n                Label {\n                    text: object.globalStatus\n                    Layout.preferredWidth: statusMetrics.width + 20\n                    Layout.preferredHeight: parent.height\n                    horizontalAlignment: Label.AlignHCenter\n                    verticalAlignment: Label.AlignVCenter\n                    color: object === uigraph.selectedNode ? Colors.sysPalette.window : Colors.sysPalette.text\n                    background: Rectangle {\n                        color: object === uigraph.selectedNode ? Colors.sysPalette.text : bgColor\n                    }\n\n                    MouseArea {\n                        anchors.fill: parent\n                        onPressed: {\n                            selectNode(object)\n                        }\n                    }\n                }\n                Label {\n                    text: getNbFinishedChunks(object.chunks) + \"/\" + object.chunks.count\n                    Layout.preferredWidth: chunksMetrics.width + 20\n                    Layout.preferredHeight: parent.height\n                    horizontalAlignment: Label.AlignHCenter\n                    verticalAlignment: Label.AlignVCenter\n                    color: object === uigraph.selectedNode ? Colors.sysPalette.window : Colors.sysPalette.text\n                    background: Rectangle {\n                        color: object === uigraph.selectedNode ? Colors.sysPalette.text : bgColor\n                    }\n\n                    MouseArea {\n                        anchors.fill: parent\n                        onPressed: {\n                            selectNode(object)\n                        }\n                    }\n                }\n                Label {\n                    text: object.jobName\n                    elide: Text.ElideRight\n                    Layout.preferredWidth: execMetrics.width + 60\n                    Layout.preferredHeight: parent.height\n                    horizontalAlignment: Label.AlignHCenter\n                    verticalAlignment: Label.AlignVCenter\n                    color: object === uigraph.selectedNode ? Colors.sysPalette.window : Colors.sysPalette.text\n                    background: Rectangle {\n                        color: object === uigraph.selectedNode ? Colors.sysPalette.text : bgColor\n                    }\n\n                    MouseArea {\n                        anchors.fill: parent\n                        onPressed: {\n                            selectNode(object)\n                        }\n                    }\n                }\n                Item {\n                    Layout.fillWidth: true\n                    Layout.minimumWidth: progressMetrics.width + 20\n                    Layout.preferredHeight: parent.height\n\n                    ListView {\n                        id: chunkList\n                        width: parent.width\n                        height: parent.height\n                        orientation: ListView.Horizontal\n                        model: object.chunks\n                        property var node: object\n\n                        spacing: 3\n                        \n                        delegate: Loader {\n                            id: chunkDelegate\n                            width: ListView.view.model \n                                ? (ListView.view.width - (ListView.view.model.count - 1) * chunkList.spacing) / ListView.view.model.count\n                                : 0\n\n                            height: ListView.view.height\n\n                            sourceComponent: Label {\n                                anchors.fill: parent\n                                background: Rectangle {\n                                    color: Colors.getChunkColor(object, {\"NONE\": bgColor})\n                                    radius: 3\n                                    border.width: 2\n                                    border.color: (root.selectedChunk == object) ? Qt.darker(color, 1.3) : \"transparent\"\n                                }\n\n                                MouseArea {\n                                    anchors.fill: parent\n                                    onPressed: {\n                                        selectNode(chunkList.node)\n                                        selectChunk(object)\n                                    }\n                                }\n                            }\n                        }\n\n                        // Placeholder for uninitialized chunks\n                        Label {\n                            enabled: chunkList.model.count == 0\n                            visible: enabled\n                            anchors.fill: parent\n                            background: Rectangle {\n                                color: Colors.getNodeColor(chunkList.node, {\"NONE\": Colors.darkpurple})\n                                radius: 3\n                                border.width: 2\n                                border.color: (chunkList.node === uigraph.selectedNode) ? Qt.lighter(color, 1.3) : \"transparent\"\n                            }\n\n                            MouseArea {\n                                anchors.fill: parent\n                                onPressed: {\n                                    selectNode(chunkList.node)\n                                    selectChunk(null)\n                                }\n                            }\n                        }\n                    }\n                }\n            }\n        }\n    }\n}\n"
  },
  {
    "path": "meshroom/ui/qml/GraphEditor/qmldir",
    "content": "module GraphEditor\n\nGraphEditor 1.0 GraphEditor.qml\nNodeEditor 1.0 NodeEditor.qml\nNode 1.0 Node.qml\nNodeChunks 1.0 NodeChunks.qml\nEdge 1.0 Edge.qml\nBackdrop 1.0 Backdrop.qml\nAttributePin 1.0 AttributePin.qml\nAttributeEditor 1.0 AttributeEditor.qml\nAttributeItemDelegate 1.0 AttributeItemDelegate.qml\nCompatibilityBadge 1.0 CompatibilityBadge.qml\nCompatibilityManager 1.0 CompatibilityManager.qml\nsingleton GraphEditorSettings 1.0 GraphEditorSettings.qml\nTaskManager 1.0 TaskManager.qml\nScriptEditor 1.0 ScriptEditor.qml\nNodeFileBrowser 1.0 NodeFileBrowser.qml"
  },
  {
    "path": "meshroom/ui/qml/Homepage.qml",
    "content": "import QtQuick\nimport QtQuick.Controls\nimport QtQuick.Layouts\n\nimport Utils 1.0\nimport MaterialIcons 2.2\nimport Controls 1.0\n\nPage {\n    id: root\n\n    onVisibleChanged: {\n        logo.playing = false\n        if (visible) {\n            logo.playing = true\n        }\n    }\n\n    MSplitView {\n        id: splitView\n        orientation: Qt.Horizontal\n        anchors.fill: parent\n\n        Item {\n            SplitView.minimumWidth: 250\n            SplitView.preferredWidth: 330\n            SplitView.maximumWidth: 500\n\n            ColumnLayout {\n                id: leftColumn\n                anchors.fill: parent\n                spacing: 20\n\n                AnimatedImage {\n                    id: logo\n                    property var ratio: sourceSize.width / sourceSize.height\n                    Layout.fillWidth: true\n                    fillMode: Image.PreserveAspectFit\n                    // Enforce aspect ratio of the component, as the fillMode does not do the job\n                    Layout.preferredHeight: width / ratio\n                    smooth: true\n                    source: \"../img/meshroom-anim-once.gif\"\n                }\n\n                ColumnLayout {\n                    Layout.fillWidth: true\n                    Layout.fillHeight: true\n                    Layout.margins: 5\n                    Layout.leftMargin: 20\n\n                    property real buttonFontSize: 14\n\n                    MaterialToolLabelButton {\n                        id: manualButton\n                        iconText: MaterialIcons.open_in_new\n                        label: \"Manual\"\n                        font.pointSize: parent.buttonFontSize\n                        Layout.fillWidth: true\n                        onClicked: Qt.openUrlExternally(\"https://meshroom-manual.readthedocs.io\")\n                    }\n\n                    MaterialToolLabelButton {\n                        id: releaseNotesButton\n                        iconText: MaterialIcons.open_in_new\n                        label: \"Release Notes\"\n                        font.pointSize: parent.buttonFontSize\n                        Layout.fillWidth: true\n                        onClicked: Qt.openUrlExternally(\"https://github.com/alicevision/Meshroom/blob/develop/CHANGES.md\")\n                    }\n\n                    MaterialToolLabelButton {\n                        id: websiteButton\n                        iconText: MaterialIcons.open_in_new\n                        label: \"Website\"\n                        font.pointSize: parent.buttonFontSize\n                        Layout.fillWidth: true\n                        onClicked: Qt.openUrlExternally(\"https://alicevision.org\")\n                    }\n                }\n\n                ColumnLayout {\n                    id: sponsors\n                    Layout.fillWidth: true\n                    Layout.alignment: Qt.AlignHCenter\n                    spacing: 5\n\n                    Item {\n                        // Empty area that expands\n                        Layout.fillWidth: true\n                        Layout.fillHeight: true\n                    }\n\n                    Label {\n                        Layout.alignment: Qt.AlignHCenter\n                        text: \"Sponsors\"\n                    }\n\n                    RowLayout {\n                        id: brandsRow\n\n                        Layout.fillWidth: true\n                        Layout.alignment: Qt.AlignHCenter\n                        spacing: 20\n\n                        Image {\n                            source: \"../img/MPC.png\"\n\n                            MouseArea {\n                                anchors.fill: parent\n                                cursorShape: Qt.PointingHandCursor\n                                onClicked: Qt.openUrlExternally(\"https://www.mpcvfx.com/\")\n\n                                hoverEnabled: true\n                                ToolTip.visible: containsMouse\n                                ToolTip.text: \"MPC - Moving Picture Company\"\n                            }\n                        }\n\n                        Image {\n                            source: \"../img/MILL.png\"\n\n                            MouseArea {\n                                anchors.fill: parent\n                                cursorShape: Qt.PointingHandCursor\n                                onClicked: Qt.openUrlExternally(\"https://www.themill.com/\")\n\n                                hoverEnabled: true\n                                ToolTip.visible: containsMouse\n                                ToolTip.text: \"The Mill\"\n                            }\n                        }\n\n                        Image {\n                            source: \"../img/MIKROS.png\"\n\n                            MouseArea {\n                                anchors.fill: parent\n                                cursorShape: Qt.PointingHandCursor\n                                onClicked: Qt.openUrlExternally(\"https://www.mikrosanimation.com/\")\n\n                                hoverEnabled: true\n                                ToolTip.visible: containsMouse\n                                ToolTip.text: \"Mikros Animation\"\n                            }\n                        }\n                    }\n\n                    RowLayout {\n                        id: academicRow\n\n                        Layout.fillWidth: true\n                        Layout.alignment: Qt.AlignHCenter\n                        spacing: 28\n\n                        Image {\n                            source: \"../img/logo_IRIT.png\"\n\n                            MouseArea {\n                                anchors.fill: parent\n                                cursorShape: Qt.PointingHandCursor\n                                onClicked: Qt.openUrlExternally(\"https://www.irit.fr/en/departement/dep-hpc-simulation-optimization/reva-team\")\n\n                                hoverEnabled: true\n                                ToolTip.visible: containsMouse\n                                ToolTip.text: \"IRIT - Institut de Recherche en Informatique de Toulouse\"\n                            }\n                        }\n\n                        Image {\n                            source: \"../img/logo_CTU.png\"\n\n                            MouseArea {\n                                anchors.fill: parent\n                                cursorShape: Qt.PointingHandCursor\n                                onClicked: Qt.openUrlExternally(\"http://aag.ciirc.cvut.cz\")\n\n                                hoverEnabled: true\n                                ToolTip.visible: containsMouse\n                                ToolTip.text: \"CTU - Czech Technical University in Prague\"\n                            }\n                        }\n\n                        Image {\n                            source: \"../img/logo_uio.png\"\n\n                            MouseArea {\n                                anchors.fill: parent\n                                cursorShape: Qt.PointingHandCursor\n                                onClicked: Qt.openUrlExternally(\"https://www.mn.uio.no/ifi/english/about/organisation/dis\")\n\n                                hoverEnabled: true\n                                ToolTip.visible: containsMouse\n                                ToolTip.text: \"UiO - University of Oslo\"\n                            }\n                        }\n\n                        Image {\n                            source: \"../img/logo_enpc.png\"\n\n                            MouseArea {\n                                anchors.fill: parent\n                                cursorShape: Qt.PointingHandCursor\n                                onClicked: Qt.openUrlExternally(\"https://imagine-lab.enpc.fr\")\n\n                                hoverEnabled: true\n                                ToolTip.visible: containsMouse\n                                ToolTip.text: \"ENPC - Ecole des Ponts ParisTech\"\n                            }\n                        }\n                    }\n\n                    MaterialToolLabelButton {\n                        Layout.topMargin: 10\n                        Layout.bottomMargin: 10\n                        Layout.alignment: Qt.AlignHCenter\n                        label: \"Support AliceVision\"\n                        iconText: MaterialIcons.favorite\n\n                        // Slightly \"extend\" the clickable area for the button while preserving the centered layout\n                        iconItem.leftPadding: 15\n                        labelItem.rightPadding: 15\n\n                        onClicked: Qt.openUrlExternally(\"https://alicevision.org/association/#donate\")\n                    }\n                }\n            }\n        }\n        \n        ColumnLayout {\n            id: rightColumn\n            SplitView.minimumWidth: 300\n            SplitView.fillWidth: true\n\n            TabPanel {\n                id: tabPanel\n                tabs: [\"Pipelines\", \"Projects\"]\n\n                Layout.fillWidth: true\n                Layout.fillHeight: true\n\n                ListView {\n                    id: pipelinesListView\n                    visible: tabPanel.currentTab === 0\n\n                    anchors.fill: parent\n                    anchors.margins: 10\n\n                    model: [{ \"name\": \"New Empty Project\", \"path\": null }].concat(MeshroomApp.pipelineTemplateFiles)\n\n                    delegate: Button {\n                        id: pipelineDelegate\n                        padding: 10\n                        width: pipelinesListView.width\n\n                        contentItem: Label {\n                            id: pipeline\n                            horizontalAlignment: Text.AlignLeft\n                            verticalAlignment: Text.AlignVCenter\n                            text: modelData[\"name\"]\n                        }\n\n                        Connections {\n                            target: pipelineDelegate\n                            function onClicked() {\n                                // Open pipeline\n                                mainStack.push(\"Application.qml\")\n                                _currentScene.new(modelData[\"path\"])\n                            }\n                        }\n                    }\n                }\n\n                GridView {\n                    id: homepageGridView\n                    visible: tabPanel.currentTab === 1\n                    anchors.fill: parent\n                    anchors.topMargin: cellHeight * 0.1\n\n                    cellWidth: 195\n                    cellHeight: cellWidth\n                    anchors.margins: 10\n\n                    model: {\n                        // Request latest thumbnail paths\n                        if (mainStack.currentItem instanceof Homepage)\n                            MeshroomApp.updateRecentProjectFilesThumbnails()\n                        return [{\"path\": null, \"thumbnail\": null, \"status\": null}].concat(MeshroomApp.recentProjectFiles)\n                    }\n\n                    // Update grid item when corresponding thumbnail is computed\n                    Connections {\n                        target: ThumbnailCache\n                        function onThumbnailCreated(imgSource, callerID) {\n                            let item = homepageGridView.itemAtIndex(callerID);  // item is an Image\n                            if (item && item.source === imgSource) {\n                                item.updateThumbnail()\n                                return\n                            }\n                            // fallback in case the Image cellID changed\n                            for (let idx = 0; idx < homepageGridView.count; idx++) {\n                                item = homepageGridView.itemAtIndex(idx)\n                                if (item && item.source === imgSource) {\n                                    item.updateThumbnail()\n                                }\n                            }\n                        }\n                    }\n\n                    delegate: Column {\n                        id: projectContent\n\n                        width: homepageGridView.cellWidth\n                        height: homepageGridView.cellHeight\n\n                        property var source: modelData[\"thumbnail\"] ? Filepath.stringToUrl(modelData[\"thumbnail\"]) : \"\"\n\n                        function updateThumbnail() {\n                            thumbnail.source = ThumbnailCache.thumbnail(source, homepageGridView.currentIndex)\n                        }\n\n                        onSourceChanged: updateThumbnail()\n\n                        Button {\n                            id: projectDelegate\n                            height: homepageGridView.cellHeight * 0.95 - project.height\n                            width: homepageGridView.cellWidth * 0.9\n\n                            // Handle case where the file is missing\n                            property bool fileExists: modelData[\"status\"] != 0\n                            opacity: fileExists ? 1.0 : 0.3\n\n                            ToolTip.visible: hovered\n                            ToolTip.text: modelData[\"path\"] ? modelData[\"path\"] : \"Open browser to select a project file\"\n\n                            font.family: MaterialIcons.fontFamily\n                            font.pointSize: 24\n\n                            text: modelData[\"path\"] ? (modelData[\"thumbnail\"] ? \"\" : MaterialIcons.description) : MaterialIcons.folder_open\n                            \n                            MouseArea {\n                                anchors.fill: parent\n                                acceptedButtons: Qt.LeftButton | Qt.RightButton\n                                hoverEnabled: true\n\n                                onClicked: function(mouse) {\n\n                                    if (mouse.button === Qt.RightButton) {\n\n                                        if (!modelData[\"path\"]) { return }\n\n                                        projectContextMenu.x = mouse.x\n                                        projectContextMenu.y = mouse.y\n                                        projectContextMenu.open()\n                                        return\n                                        \n                                    }\n                                        \n                                    if (!modelData[\"path\"]) {\n                                        initFileDialogFolder(openFileDialog)\n                                        openFileDialog.open()\n                                    } \n                                    \n                                    else {\n                                        // Open project\n                                        mainStack.push(\"Application.qml\")\n                                        if (_currentScene.load(modelData[\"path\"])) {\n                                            MeshroomApp.addRecentProjectFile(modelData[\"path\"])\n                                        }\n                                    }\n                                    \n                                }\n\n                            }\n\n                            Menu {\n                                id: projectContextMenu\n\n                                MenuItem {\n                                    enabled: projectDelegate.fileExists\n                                    text: \"Open\"\n                                    onTriggered: {                                        \n                                        if (_currentScene.load(modelData[\"path\"])) {\n                                            mainStack.push(\"Application.qml\")\n                                            MeshroomApp.addRecentProjectFile(modelData[\"path\"])\n                                        }\n                                    }\n                                }\n\n                                MenuItem {\n                                    text: \"Copy Path\"\n                                    onTriggered: {                                        \n                                        Clipboard.clear()\n                                        Clipboard.setText(modelData[\"path\"])\n                                    }\n                                }\n\n                                MenuItem {\n                                    text: \"Delete\"\n                                    onTriggered: {\n                                        MeshroomApp.removeRecentProjectFile(modelData[\"path\"])\n                                    }\n                                }\n                            }\n\n                            Image {\n                                id: thumbnail\n                                visible: modelData[\"thumbnail\"]\n                                cache: false\n                                asynchronous: true\n\n                                fillMode: Image.PreserveAspectCrop\n\n                                width: projectDelegate.width\n                                height: projectDelegate.height\n                            }\n\n                            BusyIndicator {\n                                anchors.centerIn: parent\n                                running: homepageGridView.visible && modelData[\"thumbnail\"] && thumbnail.status != Image.Ready\n                                visible: running\n                            }\n\n                        }\n                        Label {\n                            id: project\n                            anchors.horizontalCenter: projectDelegate.horizontalCenter\n                            horizontalAlignment: Text.AlignHCenter\n                            width: projectDelegate.width\n                            elide: Text.ElideMiddle\n                            text: modelData[\"path\"] ? Filepath.basename(modelData[\"path\"]) : \"Open Project\"\n                            maximumLineCount: 1\n                            font.pointSize: 10\n                        }\n                    }\n                }\n            }\n        }\n    }\n}\n"
  },
  {
    "path": "meshroom/ui/qml/ImageGallery/ImageBadge.qml",
    "content": "import QtQuick\n\nimport MaterialIcons 2.2\nimport Utils 1.0\n\n/**\n * ImageBadge is a preset MaterialLabel to display an icon bagdge on an image.\n */\n\nMaterialLabel {\n    id: root\n\n    font.pointSize: 10\n    padding: 2\n    background: Rectangle {\n        color: Colors.sysPalette.window\n        opacity: 0.6\n    }\n}\n"
  },
  {
    "path": "meshroom/ui/qml/ImageGallery/ImageDelegate.qml",
    "content": "import QtQuick\nimport QtQuick.Controls\nimport QtQuick.Layouts\n\nimport MaterialIcons 2.2\nimport Utils 1.0\n\n/**\n * ImageDelegate for a Viewpoint object.\n */\n\nItem {\n    id: root\n\n    property variant viewpoint\n    property int cellID: -1\n    property alias source: _viewpoint.source\n    property alias metadata: _viewpoint.metadata\n    property bool readOnly: false\n    property bool displayViewId: false\n    property bool displayThumbnail: true\n    property int layoutMode: 0  // 0: grid, 1: list\n\n    property variant parentModel\n    property int selectedIndex: parentModel ? parentModel.selectedIndex : -1\n    property bool isCurrentItem: cellID >= 0 && cellID === selectedIndex\n\n    signal pressed(var mouse)\n    signal removeRequest()\n    signal removeAllImagesRequest()\n\n    default property alias children: imageMA.children\n\n    // Internal properties to hold thumbnail source & loading status\n    property url thumbnailSource: \"\"\n    property int thumbnailStatus: Image.Null\n\n    // Retrieve viewpoints inner data\n    QtObject {\n        id: _viewpoint\n        property url source: viewpoint ? Filepath.stringToUrl(viewpoint.get(\"path\").value) : ''\n        property int viewId: viewpoint ? viewpoint.get(\"viewId\").value : -1\n        property string metadataStr: viewpoint ? viewpoint.get(\"metadata\").value : ''\n        property var metadata: metadataStr ? JSON.parse(viewpoint.get(\"metadata\").value) : {}\n    }\n\n    // Update thumbnail location\n    // Can be called from the GridView when a new thumbnail has been written on disk\n    function updateThumbnail() {\n        if (!displayThumbnail) return\n        root.thumbnailSource = ThumbnailCache.thumbnail(root.source, root.cellID)\n    }\n    onSourceChanged: {\n        updateThumbnail()\n    }\n    onDisplayThumbnailChanged: {\n        if (displayThumbnail)\n            updateThumbnail()\n        else\n            root.thumbnailSource = \"\"\n    }\n\n    // Send a new request after 5 seconds if thumbnail is not loaded\n    // This is meant to avoid deadlocks in case there is a synchronization problem\n    Timer {\n        interval: 5000\n        running: true\n        onTriggered: {\n            if (root.thumbnailStatus == Image.Null) {\n                updateThumbnail()\n            }\n        }\n    }\n\n    MouseArea {\n        id: imageMA\n        anchors.fill: parent\n        anchors.margins: 6\n        hoverEnabled: true\n        acceptedButtons: Qt.LeftButton | Qt.RightButton\n        onPressed: function(mouse) {\n            if (mouse.button == Qt.RightButton)\n                imageMenu.popup()\n            root.pressed(mouse)\n        }\n\n        Menu {\n            id: imageMenu\n            MenuItem {\n                text: \"Show Containing Folder\"\n                onClicked: {\n                    Qt.openUrlExternally(Filepath.dirname(root.source))\n                }\n            }\n            MenuItem {\n                text: \"Remove\"\n                enabled: !root.readOnly\n                onClicked: removeRequest()\n            }\n            MenuItem {\n                text: \"Remove All Images\"\n                enabled: !root.readOnly\n                onClicked: removeAllImagesRequest()\n            }\n            MenuItem {\n                text: \"Define As Center Image\"\n                property var activeNode: _currentScene ? _currentScene.activeNodes.get(\"SfMTransform\").node : null\n                enabled: !root.readOnly && _viewpoint.viewId != -1 && _currentScene && activeNode\n                onClicked: _currentScene.setAttribute(activeNode.attribute(\"transformation\"), _viewpoint.viewId.toString())\n            }\n            Menu {\n                id: sfmSetPairMenu\n                title: \"SfM: Define Initial Pair\"\n                property var activeNode: _currentScene ? _currentScene.activeNodes.get(\"StructureFromMotion\").node : null\n                enabled: !root.readOnly && _viewpoint.viewId != -1 && _currentScene && activeNode\n\n                MenuItem {\n                    text: \"A\"\n                    onClicked: _currentScene.setAttribute(sfmSetPairMenu.activeNode.attribute(\"initialPairA\"), _viewpoint.viewId.toString())\n                }\n\n                MenuItem {\n                    text: \"B\"\n                    onClicked: _currentScene.setAttribute(sfmSetPairMenu.activeNode.attribute(\"initialPairB\"), _viewpoint.viewId.toString())\n                }\n            }\n        }\n\n        // Switch from the grid component (column layout) to the list component (row layout)\n        Loader {\n            id: itemDelegate\n            anchors.fill: parent\n            sourceComponent: root.layoutMode === 0 ? gridDelegate : listDelegate\n        }\n\n        Component {\n            id: gridDelegate\n            ColumnLayout {\n                anchors.fill: parent\n                spacing: 0\n\n                // Image thumbnail and background\n                Rectangle {\n                    color: Qt.darker(grid_imageLabel.palette.base, 1.15)\n                    Layout.fillHeight: true\n                    Layout.fillWidth: true\n                    visible: root.displayThumbnail\n                    border.color: isCurrentItem ? grid_imageLabel.palette.highlight : Qt.darker(grid_imageLabel.palette.highlight)\n                    border.width: imageMA.containsMouse || root.isCurrentItem ? 2 : 0\n                    Image {\n                        id: grid_thumbnail\n                        anchors.fill: parent\n                        anchors.margins: 4\n                        source: root.thumbnailSource\n                        asynchronous: true\n                        autoTransform: true\n                        fillMode: Image.PreserveAspectFit\n                        smooth: false\n                        cache: false\n                        onStatusChanged: root.thumbnailStatus = status\n                    }\n                    BusyIndicator {\n                        anchors.centerIn: parent\n                        running: grid_thumbnail.status != Image.Ready\n                    }\n                }\n\n                // Placeholder icon shown when thumbnails are disabled\n                Label {\n                    Layout.fillHeight: true\n                    Layout.fillWidth: true\n                    visible: !root.displayThumbnail\n                    horizontalAlignment: Text.AlignHCenter\n                    verticalAlignment: Text.AlignVCenter\n                    text: MaterialIcons.image\n                    font.family: MaterialIcons.fontFamily\n                    font.pointSize: 16\n                    color: palette.mid\n                }\n\n                // Image basename\n                Label {\n                    id: grid_imageLabel\n                    Layout.fillWidth: true\n                    padding: 2\n                    font.pointSize: 8\n                    elide: Text.ElideMiddle\n                    horizontalAlignment: Text.AlignHCenter\n                    text: Filepath.basename(root.source)\n                    background: Rectangle {\n                        color: root.isCurrentItem ? parent.palette.highlight : \"transparent\"\n                    }\n                }\n\n                // Image viewId\n                Loader {\n                    active: displayViewId\n                    Layout.fillWidth: true\n                    visible: active\n                    sourceComponent: Label {\n                        padding: grid_imageLabel.padding\n                        font.pointSize: grid_imageLabel.font.pointSize\n                        elide: grid_imageLabel.elide\n                        horizontalAlignment: grid_imageLabel.horizontalAlignment\n                        text: _viewpoint.viewId\n                        background: Rectangle {\n                            color: grid_imageLabel.background.color\n                        }\n                    }\n                }\n            }\n        }\n\n        Component {\n            id: listDelegate\n            RowLayout {\n                anchors.fill: parent\n                spacing: 4\n\n                // Image thumbnail and background\n                Rectangle {\n                    color: Qt.darker(list_imageLabel.palette.base, 1.15)\n                    Layout.fillHeight: true\n                    Layout.preferredWidth: 100\n                    visible: root.displayThumbnail\n                    \n                    border.color: isCurrentItem ? list_imageLabel.palette.highlight : Qt.darker(list_imageLabel.palette.highlight)\n                    border.width: imageMA.containsMouse || root.isCurrentItem ? 2 : 0\n\n                    Image {\n                        id: list_thumbnail\n                        anchors.fill: parent\n                        anchors.margins: 4\n                        source: root.thumbnailSource\n                        asynchronous: true\n                        autoTransform: true\n                        fillMode: Image.PreserveAspectFit\n                        smooth: false\n                        cache: false\n                        onStatusChanged: root.thumbnailStatus = status\n                    }\n                    BusyIndicator {\n                        anchors.centerIn: parent\n                        running: list_thumbnail.status != Image.Ready\n                    }\n                }\n\n                // Placeholder icon shown when thumbnails are disabled\n                Label {\n                    Layout.fillHeight: true\n                    visible: !root.displayThumbnail\n                    horizontalAlignment: Text.AlignHCenter\n                    verticalAlignment: Text.AlignVCenter\n                    text: MaterialIcons.image\n                    font.family: MaterialIcons.fontFamily\n                    font.pointSize: 14\n                    color: palette.mid\n                }\n\n                ColumnLayout {\n                    Layout.fillWidth: true\n                    Layout.fillHeight: true\n                    spacing: 0\n\n                    // Image basename\n                    Label {\n                        id: list_imageLabel\n                        Layout.fillWidth: true\n                        Layout.fillHeight: true\n                        padding: 4\n                        font.pointSize: 8\n                        elide: Text.ElideMiddle\n                        horizontalAlignment: Text.AlignLeft\n                        verticalAlignment: Text.AlignVCenter\n                        text: Filepath.basename(root.source)\n                        background: Rectangle {\n                            color: root.isCurrentItem ? parent.palette.highlight : \"transparent\"\n                        }\n                    }\n\n                    // Image viewId\n                    Loader {\n                        active: root.displayViewId\n                        Layout.fillWidth: true\n                        Layout.fillHeight: active\n                        visible: active\n                        sourceComponent: Label {\n                            padding: list_imageLabel.padding\n                            font.pointSize: list_imageLabel.font.pointSize\n                            elide: list_imageLabel.elide\n                            horizontalAlignment: list_imageLabel.horizontalAlignment\n                            verticalAlignment: list_imageLabel.verticalAlignment\n                            text: _viewpoint.viewId\n                            background: Rectangle {\n                                color: list_imageLabel.background.color\n                            }\n                        }\n                    }\n                }\n            }\n        }\n    }\n}"
  },
  {
    "path": "meshroom/ui/qml/ImageGallery/ImageGallery.qml",
    "content": "import QtQuick\nimport QtQuick.Controls\nimport QtQuick.Layouts\nimport QtQml.Models\nimport Qt.labs.qmlmodels\n\nimport Controls 1.0\nimport MaterialIcons 2.2\nimport Utils 1.0\n\n/**\n * ImageGallery displays as a grid of Images a model containing Viewpoints objects.\n * It manages a model of multiple CameraInit nodes as individual groups.\n */\n\nPanel {\n    id: root\n\n    property variant cameraInits\n    property variant cameraInit\n    property int cameraInitIndex\n    property variant tempCameraInit\n\n    readonly property var currentItem: layoutLoader.item ? layoutLoader.item.currentItem : null\n    readonly property string currentItemSource: currentItem ? currentItem.source : \"\"\n    readonly property var currentItemMetadata: currentItem ? currentItem.metadata : undefined\n    readonly property int centerViewId: (_currentScene && _currentScene.sfmTransform) ? parseInt(_currentScene.sfmTransform.attribute(\"transformation\").value) : 0\n    readonly property var galleryGrid: layoutLoader.item  // This now references the loaded view (grid or list)\n\n    property int defaultCellSize: 160\n    property bool readOnly: false\n\n    enum LayoutModes {\n        Grid=0,\n        List=1\n    }\n\n    property int displayMode: ImageGallery.LayoutModes.Grid\n\n    property var filesByType: ({})\n    property int nbMeshroomScenes: 0\n    property int nbDraggedFiles: 0\n\n    signal removeImageRequest(var attribute)\n    signal allViewpointsCleared()\n    signal filesDropped(var drop)\n\n    title: \"Image Gallery\"\n    implicitWidth: (root.defaultCellSize + 2) * 2\n\n    Connections {\n        target: _currentScene\n\n        function onCameraInitChanged() {\n            nodesCB.currentIndex = root.cameraInitIndex\n        }\n    }\n\n    QtObject {\n        id: m\n        property variant currentCameraInit: _currentScene && _currentScene.tempCameraInit ? _currentScene.tempCameraInit : root.cameraInit\n        property variant viewpoints: currentCameraInit ? currentCameraInit.attribute('viewpoints').value : undefined\n        property variant intrinsics: currentCameraInit ? currentCameraInit.attribute('intrinsics').value : undefined\n        property bool readOnly: ((_currentScene && currentCameraInit) ? currentCameraInit.locked : root.readOnly) || displayHDR.checked\n\n        onViewpointsChanged: {\n            ThumbnailCache.clearRequests()\n        }\n\n        onIntrinsicsChanged: {\n            parseIntr()\n        }\n    }\n\n    property variant parsedIntrinsic\n    property int numberOfIntrinsics: m.intrinsics ? m.intrinsics.count : 0\n    onNumberOfIntrinsicsChanged: {\n        parseIntr()\n    }\n\n    function changeCurrentIndex(newIndex) {\n        _currentScene.cameraInitIndex = newIndex\n    }\n\n    function populate_model() {\n        if (!intrinsicModel.ready) {\n            // If the TableModel is not done being instantiated, do nothing\n            return\n        }\n\n        intrinsicModel.clear()\n        for (var intr in parsedIntrinsic) {\n            intrinsicModel.appendRow(parsedIntrinsic[intr])\n        }\n    }\n\n    function parseIntr() {\n        parsedIntrinsic = []\n        if (!m.intrinsics) {\n            return\n        }\n\n        // Loop through all intrinsics\n        for (var i = 0; i < m.intrinsics.count; ++i) {\n            var intrinsic = {}\n\n            // Loop through all attributes\n            for (var j = 0; j < m.intrinsics.at(i).value.count; ++j) {\n                var currentAttribute = m.intrinsics.at(i).value.at(j)\n                if (currentAttribute.type === \"GroupAttribute\") {\n                    for (var k = 0; k < currentAttribute.value.count; ++k) {\n                        intrinsic[currentAttribute.name + \".\" + currentAttribute.value.at(k).name] = currentAttribute.value.at(k)\n                    }\n                } else if (currentAttribute.type === \"ListAttribute\") {\n                    // Not needed for now\n                } else {\n                    intrinsic[currentAttribute.name] = currentAttribute\n                }\n            }\n            // Table Model needs to contain an entry for each column.\n            // In case of old file formats, some intrinsic keys that we display may not exist in the model.\n            // So, here we create an empty entry to enforce that the key exists in the model.\n            for (var n = 0; n < intrinsicModel.columnNames.length; ++n) {\n                var name = intrinsicModel.columnNames[n]\n                if (!(name in intrinsic)) {\n                    intrinsic[name] = {}\n                }\n            }\n            parsedIntrinsic[i] = intrinsic\n        }\n        populate_model()\n    }\n\n    function toggleDisplayMode() {\n        displayMode = displayMode === ImageGallery.LayoutModes.Grid ? \n            ImageGallery.LayoutModes.List : ImageGallery.LayoutModes.Grid\n    }\n\n    headerBar: RowLayout {\n        SearchBar {\n            id: searchBar\n            toggle: true  // Enable toggling the actual text field by the search button\n            Layout.minimumWidth: searchBar.width\n            maxWidth: 150\n        }\n\n        MaterialToolButton {\n            text: root.displayMode === ImageGallery.LayoutModes.Grid ? MaterialIcons.view_list : MaterialIcons.view_module\n            font.pointSize: 11\n            padding: 2\n            ToolTip.text: \"Switch the layout to \" + root.displayMode === ImageGallery.LayoutModes.Grid ? \"List\" : \"Grid\"\n            ToolTip.visible: hovered\n            onClicked: root.toggleDisplayMode()\n        }\n\n        MaterialToolButton {\n            text: MaterialIcons.more_vert\n            font.pointSize: 11\n            padding: 2\n            checkable: true\n            checked: galleryMenu.visible\n            onClicked: galleryMenu.open()\n            Menu {\n                id: galleryMenu\n                y: parent.height\n                x: -width + parent.width\n                MenuItem {\n                    text: \"Edit Sensor Database...\"\n                    onTriggered: {\n                        sensorDBDialog.open()\n                    }\n                }\n\n                Menu {\n                    title: \"Advanced\"\n                    Action {\n                        id: displayViewIdsAction\n                        text: \"Display View IDs\"\n                        checkable: true\n                    }\n                }\n            }\n        }\n    }\n\n    SensorDBDialog {\n        id: sensorDBDialog\n        sensorDatabase: cameraInit ? Filepath.stringToUrl(cameraInit.attribute(\"sensorDatabase\").evalValue) : \"\"\n        readOnly: _currentScene ? _currentScene.computing : false\n        onUpdateIntrinsicsRequest: _currentScene.rebuildIntrinsics(cameraInit)\n    }\n\n    SortFilterDelegateModel {\n        id: sortedModel\n        model: m.viewpoints\n        sortRole: \"path.basename\"\n        filters: displayViewIdsAction.checked ? filtersWithViewIds : filtersBasic\n        property var filtersBasic: [\n            {role: \"path\", value: searchBar.text},\n            {role: \"viewId.isReconstructed\", value: reconstructionFilter}\n        ]\n        property var filtersWithViewIds:  [\n            [\n                {role: \"path\", value: searchBar.text}, \n                {role: \"viewId.asString\", value: searchBar.text}\n            ], \n            {role: \"viewId.isReconstructed\", value: reconstructionFilter}\n        ]\n        property var reconstructionFilter: undefined\n\n        // Override modelData to return basename of viewpoint's path for sorting\n        function modelData(item, roleName_) {\n            var roleNameAndCmd = roleName_.split(\".\")\n            var roleName = roleName_\n            var cmd = \"\"\n            if (roleNameAndCmd.length >= 2) {\n                roleName = roleNameAndCmd[0]\n                cmd = roleNameAndCmd[1]\n            }\n            if (cmd === \"isReconstructed\")\n                return _currentScene.isReconstructed(item.model.object);\n\n            var value = item.model.object.childAttribute(roleName).value;\n            if (cmd === \"basename\")\n                return Filepath.basename(value);\n            if (cmd === \"asString\") \n                return value.toString();\n\n            return value\n        }\n\n        property int selectedIndex: -1\n\n        delegate: ImageDelegate {\n            id: imageDelegate\n\n            layoutMode: root.displayMode\n            viewpoint: object.value\n            cellID: DelegateModel.filteredIndex\n            width: layoutLoader.item ? (displayMode === ImageGallery.LayoutModes.List ? layoutLoader.item.width : layoutLoader.item.cellWidth) : 0\n            height: layoutLoader.item ? layoutLoader.item.cellHeight : 0\n\n            readOnly: m.readOnly\n            displayViewId: displayViewIdsAction.checked\n            displayThumbnail: thumbnailSizeSlider.value > thumbnailSizeSlider.from\n            visible: !intrinsicsFilterButton.checked\n            \n            parentModel: sortedModel\n\n            onPressed: {\n                if (layoutLoader.item) {\n                    layoutLoader.item.currentIndex = DelegateModel.filteredIndex\n                    sortedModel.selectedIndex = DelegateModel.filteredIndex\n                }\n            }\n\n            function sendRemoveRequest() {\n                if (readOnly)\n                    return\n\n                root.removeImageRequest(object)\n                \n                // If the last image has been removed, make sure the viewpoints and intrinsics are reset\n                if (m.viewpoints.count === 0)\n                    root.allViewpointsCleared()\n            }\n\n            function removeAllImages() {\n                _currentScene.removeAllImages()\n                _currentScene.selectedViewId = \"-1\"\n            }\n\n            onRemoveRequest: sendRemoveRequest()\n            Keys.onPressed: function(event) {\n                if (event.key === Qt.Key_Delete && event.modifiers === Qt.ShiftModifier) {\n                    removeAllImages()\n                } else if (event.key === Qt.Key_Delete) {\n                    sendRemoveRequest()\n                }\n            }\n            onRemoveAllImagesRequest: {\n                removeAllImages()\n            }\n\n            RowLayout {\n                anchors.top: parent.top\n                anchors.left: parent.left\n                anchors.right: parent.right\n                anchors.margins: 2\n                spacing: 2\n\n                property bool valid: Qt.isQtObject(object) // object can be evaluated to null at some point during creation/deletion\n                property bool inViews: valid && _currentScene && _currentScene.sfmReport && _currentScene.isInViews(object)\n\n                // Camera Initialization indicator\n                IntrinsicsIndicator {\n                    intrinsic: parent.valid && _currentScene ? _currentScene.getIntrinsic(object) : null\n                    metadata: imageDelegate.metadata\n                }\n\n                // Rig indicator\n                Loader {\n                    id: rigIndicator\n                    property int rigId: parent.valid ? object.childAttribute(\"rigId\").value : -1\n                    active: rigId >= 0\n                    sourceComponent: ImageBadge {\n                        property int rigSubPoseId: model.object.childAttribute(\"subPoseId\").value\n                        text: MaterialIcons.link\n                        ToolTip.text: \"<b>Rig: Initialized</b><br>\" +\n                                        \"Rig ID: \" + rigIndicator.rigId + \" <br>\" +\n                                        \"SubPose: \" + rigSubPoseId\n                    }\n                }\n\n                // Center of SfMTransform\n                Loader {\n                    id: sfmTransformIndicator\n                    active: viewpoint && (viewpoint.get(\"viewId\").value === centerViewId)\n                    sourceComponent: ImageBadge {\n                        text: MaterialIcons.gamepad\n                        ToolTip.text: \"Camera used to define the center of the scene.\"\n                    }\n                }\n\n                Item { Layout.fillWidth: true }\n\n                // Reconstruction status indicator\n                Loader {\n                    active: parent.inViews\n                    visible: active\n                    sourceComponent: ImageBadge {\n                        property bool reconstructed: _currentScene.sfmReport && _currentScene.isReconstructed(model.object)\n                        text: reconstructed ? MaterialIcons.videocam : MaterialIcons.videocam_off\n                        color: reconstructed ? Colors.green : Colors.red\n                        ToolTip.text: \"<b>Camera: \" + (reconstructed ? \"\" : \"Not \") + \"Reconstructed</b>\"\n                    }\n                }\n            }\n        }\n    }\n\n    ColumnLayout {\n        anchors.fill: parent\n        spacing: 4\n\n        Loader {\n            id: layoutLoader\n            Layout.fillWidth: true\n            Layout.fillHeight: true\n            visible: !intrinsicsFilterButton.checked\n            \n            sourceComponent: root.displayMode === ImageGallery.LayoutModes.Grid ? gridViewComponent : listViewComponent\n            \n            onLoaded: {\n                if (item) {\n                    // Pass necessary properties to the loaded component\n                    item.m = m\n                    item.gallery = root\n                    item.searchBar = searchBar\n                    item.intrinsicsFilterButton = intrinsicsFilterButton\n                    item.tempCameraInit = tempCameraInit\n                    item.errorDialog = errorDialog\n                    item.sortedModel = sortedModel\n                    item.thumbnailSizeSlider = thumbnailSizeSlider\n\n                    // Connect signals\n                    item.removeImageRequest.connect(root.removeImageRequest)\n                    item.allViewpointsCleared.connect(root.allViewpointsCleared)\n                    \n                    // Restore currentIndex (before connecting signals to avoid unwanted selection change)\n                    item.currentIndex = sortedModel.selectedIndex\n                    \n                    // Don't scroll yet because we must make sure the layout is loaded first\n                    scrollTimer.restart()\n                }\n            }\n        }\n\n        // Add a timer with a small delay so that we scroll after loading the layout\n        Timer {\n            id: scrollTimer\n            interval: 25\n            repeat: false\n            onTriggered: {\n                if (layoutLoader.item && _currentScene.selectedViewId > -1) {\n                    layoutLoader.item.updateCurrentIndexFromSelectionViewId()\n                    // Use another short delay for the actual scroll\n                    Qt.callLater(function() {\n                        if (layoutLoader.item && layoutLoader.item.currentIndex >= 0) {\n                            layoutLoader.item.makeCurrentItemVisible()\n                        }\n                    })\n                }\n            }\n        }\n        \n        Component {\n            id: gridViewComponent\n            ImageGridView {\n                id: gridView\n            }\n        }\n\n        Component {\n            id: listViewComponent\n            ImageListView {\n                id: listView\n            }\n        }\n\n        Item {\n            Layout.fillWidth: true\n            Layout.fillHeight: true\n            visible: intrinsicsFilterButton.checked\n            clip: true\n\n            TableView {\n                id : intrinsicTable\n                visible: intrinsicsFilterButton.checked\n                anchors.fill: parent\n                boundsMovement : Flickable.StopAtBounds\n\n                palette: root.palette\n\n                // Provide width for column\n                // Note no size provided for the last column (bool comp) so it uses its automated size\n                columnWidthProvider: function (column) { return intrinsicModel.columnWidths[column] }\n\n                model: intrinsicModel\n\n                delegate: IntrinsicDisplayDelegate {\n                    attribute: model.display\n                    readOnly: m.currentCameraInit ? m.currentCameraInit.locked : false\n                }\n\n                ScrollBar.horizontal: MScrollBar { id: sb }\n                ScrollBar.vertical : MScrollBar { id: sbv }\n            }\n\n            TableModel {\n                id : intrinsicModel\n                property bool ready: false\n\n                // Hardcoded default width per column\n                property var columnWidths: [105, 75, 75, 75, 60, 60, 60, 60, 200, 60, 60, 60]\n                property var columnNames: [\n                    \"intrinsicId\",\n                    \"initialFocalLength\",\n                    \"focalLength\",\n                    \"type\",\n                    \"width\",\n                    \"height\",\n                    \"sensorWidth\",\n                    \"sensorHeight\",\n                    \"serialNumber\",\n                    \"principalPoint.x\",\n                    \"principalPoint.y\",\n                    \"locked\"\n                ]\n\n                TableModelColumn { display: function(modelIndex){return parsedIntrinsic[modelIndex.row][intrinsicModel.columnNames[0]]} }\n                TableModelColumn { display: function(modelIndex){return parsedIntrinsic[modelIndex.row][intrinsicModel.columnNames[1]]} }\n                TableModelColumn { display: function(modelIndex){return parsedIntrinsic[modelIndex.row][intrinsicModel.columnNames[2]]} }\n                TableModelColumn { display: function(modelIndex){return parsedIntrinsic[modelIndex.row][intrinsicModel.columnNames[3]]} }\n                TableModelColumn { display: function(modelIndex){return parsedIntrinsic[modelIndex.row][intrinsicModel.columnNames[4]]} }\n                TableModelColumn { display: function(modelIndex){return parsedIntrinsic[modelIndex.row][intrinsicModel.columnNames[5]]} }\n                TableModelColumn { display: function(modelIndex){return parsedIntrinsic[modelIndex.row][intrinsicModel.columnNames[6]]} }\n                TableModelColumn { display: function(modelIndex){return parsedIntrinsic[modelIndex.row][intrinsicModel.columnNames[7]]} }\n                TableModelColumn { display: function(modelIndex){return parsedIntrinsic[modelIndex.row][intrinsicModel.columnNames[8]]} }\n                TableModelColumn { display: function(modelIndex){return parsedIntrinsic[modelIndex.row][intrinsicModel.columnNames[9]]} }\n                TableModelColumn { display: function(modelIndex){return parsedIntrinsic[modelIndex.row][intrinsicModel.columnNames[10]]} }\n                TableModelColumn { display: function(modelIndex){return parsedIntrinsic[modelIndex.row][intrinsicModel.columnNames[11]]} }\n                //https://doc.qt.io/qt-5/qml-qt-labs-qmlmodels-tablemodel.html#appendRow-method\n\n                Component.onCompleted: {\n                    ready = true\n                    // Triggers \"populate_model\" in case the intrinsics have been filled while the model was\n                    // being instantiated\n                    root.populate_model()\n                }\n            }\n\n            //CODE FOR HEADERS\n            //UNCOMMENT WHEN COMPATIBLE WITH THE RIGHT QT VERSION\n            // HorizontalHeaderView {\n            //     id: horizontalHeader\n            //     syncView: tableView\n            //     anchors.left: tableView.left\n            // }\n        }\n\n        RowLayout {\n            Layout.fillHeight: false\n            visible: root.cameraInits ? root.cameraInits.count > 1 : false\n            Layout.alignment: Qt.AlignHCenter\n            spacing: 2\n\n            ToolButton {\n                text: MaterialIcons.navigate_before\n                property string previousGroupName: {\n                    if (root.cameraInits && root.cameraInitIndex - 1 >= 0) {\n                        return root.cameraInits.at(root.cameraInitIndex - 1).label\n                    }\n                    return \"\"\n                }\n                font.family: MaterialIcons.fontFamily\n                ToolTip.text: \"Previous Group (Alt+Left): \" + previousGroupName\n                ToolTip.visible: hovered\n                enabled: nodesCB.currentIndex > 0\n                onClicked: nodesCB.decrementCurrentIndex()\n            }\n            Label {\n                id: groupLabel\n                text: \"Group \"\n            }\n            ComboBox {\n                id: nodesCB\n                model: {\n                    // Create an array from 1 to cameraInits.count for the\n                    // display of group indices (real indices still are from\n                    // 0 to cameraInits.count - 1)\n                    var l = [];\n                    if (root.cameraInits) {\n                        for (var i = 1; i <= root.cameraInits.count; i++) {\n                            l.push(i);\n                        }\n                    }\n                    return l;\n                }\n                implicitWidth: 40\n                currentIndex: root.cameraInitIndex\n                onActivated: root.changeCurrentIndex(currentIndex)\n            }\n            Label { text: \"/ \" + (root.cameraInits ? root.cameraInits.count : \"Unknown\") }\n            ToolButton {\n                text: MaterialIcons.navigate_next\n                property string nextGroupName: {\n                    if (root.cameraInits && root.cameraInitIndex + 1 < root.cameraInits.count) {\n                        var group = root.cameraInits.at(root.cameraInitIndex + 1)\n                        if (group)\n                            return root.cameraInits.at(root.cameraInitIndex + 1).label\n                    }\n                    return \"\"\n                }\n                font.family: MaterialIcons.fontFamily\n                ToolTip.text: \"Next Group (Alt+Right): \" + nextGroupName\n                ToolTip.visible: hovered\n                enabled: root.cameraInits ? nodesCB.currentIndex < root.cameraInits.count - 1 : false\n                onClicked: nodesCB.incrementCurrentIndex()\n            }\n        }\n\n        RowLayout {\n            Layout.fillHeight: false\n            Layout.alignment: Qt.AlignHCenter\n            visible: root.cameraInits ? root.cameraInits.count > 1 : false\n\n            Label {\n                id: groupName\n                text: root.cameraInit ? \"<b>\" + root.cameraInit.label + \"</b>\" + (root.cameraInit.label !== root.cameraInit.defaultLabel ? \" (\" + root.cameraInit.defaultLabel + \")\" : \"\") : \"\"\n                font.pointSize: 8\n            }\n        }\n    }\n\n    footerContent: RowLayout {\n        // Images count\n        id: footer\n\n        function resetButtons() {\n            inputImagesFilterButton.checked = false\n            estimatedCamerasFilterButton.checked = false\n            nonEstimatedCamerasFilterButton.checked = false\n        }\n\n        MaterialToolLabelButton {\n            id : inputImagesFilterButton\n            Layout.minimumWidth: childrenRect.width\n            ToolTip.text: (layoutLoader.item && layoutLoader.item.model ? layoutLoader.item.model.count : 0) + \" Input Images\"\n            iconText: MaterialIcons.image\n            label: (m.viewpoints ? m.viewpoints.count : 0)\n            padding: 3\n\n            checkable: true\n            checked: true\n\n            onCheckedChanged: {\n                if (checked) {\n                    sortedModel.reconstructionFilter = undefined;\n                    estimatedCamerasFilterButton.checked = false;\n                    nonEstimatedCamerasFilterButton.checked = false;\n                    intrinsicsFilterButton.checked = false;\n                } else {\n                    if (estimatedCamerasFilterButton.checked === false && nonEstimatedCamerasFilterButton.checked === false && intrinsicsFilterButton.checked === false)\n                        inputImagesFilterButton.checked = true\n                }\n            }\n        }\n        // Estimated cameras count\n        MaterialToolLabelButton {\n            id : estimatedCamerasFilterButton\n            Layout.minimumWidth: childrenRect.width\n            ToolTip.text: label + \" Estimated Cameras\"\n            iconText: MaterialIcons.videocam\n            label: _currentScene && _currentScene.nbCameras ? _currentScene.nbCameras.toString() : \"-\"\n            padding: 3\n\n            enabled: _currentScene ? _currentScene.cameraInit && _currentScene.nbCameras : false\n            checkable: true\n            checked: false\n\n            onCheckedChanged: {\n                if (checked) {\n                    sortedModel.reconstructionFilter = true\n                    inputImagesFilterButton.checked = false\n                    nonEstimatedCamerasFilterButton.checked = false\n                    intrinsicsFilterButton.checked = false\n                } else {\n                    if (inputImagesFilterButton.checked === false && nonEstimatedCamerasFilterButton.checked === false && intrinsicsFilterButton.checked === false)\n                        inputImagesFilterButton.checked = true\n                }\n            }\n            onEnabledChanged: {\n                if (!enabled) {\n                    if (checked)\n                        inputImagesFilterButton.checked = true\n                    checked = false\n                }\n            }\n        }\n\n        // Non estimated cameras count\n        MaterialToolLabelButton {\n            id : nonEstimatedCamerasFilterButton\n            Layout.minimumWidth: childrenRect.width\n            ToolTip.text: label + \" Non Estimated Cameras\"\n            iconText: MaterialIcons.videocam_off\n            label: _currentScene && _currentScene.nbCameras ? ((m.viewpoints ? m.viewpoints.count : 0) - _currentScene.nbCameras.toString()).toString() : \"-\"\n            padding: 3\n\n            enabled: _currentScene ? _currentScene.cameraInit && _currentScene.nbCameras : false\n            checkable: true\n            checked: false\n\n            onCheckedChanged: {\n                if (checked) {\n                    sortedModel.reconstructionFilter = false\n                    inputImagesFilterButton.checked = false\n                    estimatedCamerasFilterButton.checked = false\n                    intrinsicsFilterButton.checked = false\n                } else {\n                    if (inputImagesFilterButton.checked === false && estimatedCamerasFilterButton.checked === false && intrinsicsFilterButton.checked === false)\n                        inputImagesFilterButton.checked = true\n                }\n            }\n            onEnabledChanged: {\n                if (!enabled) {\n                    if (checked)\n                        inputImagesFilterButton.checked = true\n                    checked = false\n                }\n            }\n        }\n\n        MaterialToolLabelButton {\n            id : intrinsicsFilterButton\n            Layout.minimumWidth: childrenRect.width\n            ToolTip.text: label + \" Number of intrinsics\"\n            iconText: MaterialIcons.camera\n            label: _currentScene ? (m.intrinsics ? m.intrinsics.count : 0) : \"0\"\n            padding: 3\n\n            enabled: m.intrinsics ? m.intrinsics.count > 0 : false\n            checkable: true\n            checked: false\n\n            onCheckedChanged: {\n                if (checked) {\n                    inputImagesFilterButton.checked = false\n                    estimatedCamerasFilterButton.checked = false\n                    nonEstimatedCamerasFilterButton.checked = false\n                } else {\n                    if (inputImagesFilterButton.checked === false && estimatedCamerasFilterButton.checked === false && nonEstimatedCamerasFilterButton.checked === false)\n                        inputImagesFilterButton.checked = true\n                }\n            }\n            onEnabledChanged: {\n                if (!enabled) {\n                    if (checked)\n                        inputImagesFilterButton.checked = true\n                    checked = false\n                }\n            }\n        }\n\n        Item {\n            Layout.fillHeight: true\n            Layout.fillWidth: true\n        }\n\n        MaterialToolLabelButton {\n            id: displayHDR\n            Layout.minimumWidth: childrenRect.width\n            property var activeNode: _currentScene ? _currentScene.activeNodes.get(\"LdrToHdrMerge\").node : null\n            ToolTip.text: \"Visualize HDR images: \" + (activeNode ? activeNode.label : \"No Node\")\n            iconText: MaterialIcons.filter\n            label: activeNode ? activeNode.attribute(\"nbBrackets\").value : \"\"\n            visible: activeNode\n            enabled: activeNode && activeNode.isComputed && (m.viewpoints ? m.viewpoints.count > 0 : false)\n            property string nodeID: activeNode ? (activeNode.label + activeNode.isComputed) : \"\"\n            onNodeIDChanged: {\n                if (checked) {\n                    open()\n                }\n            }\n            onEnabledChanged: {\n                // Reset the toggle to avoid getting stuck with the HDR node checked but disabled\n                if (checked) {\n                    checked = false\n                    close()\n                }\n            }\n            checkable: true\n            checked: false\n            onClicked: {\n                if (checked) {\n                    open()\n                } else {\n                    close()\n                }\n            }\n            function open() {\n                if (imageProcessing.checked)\n                    imageProcessing.checked = false\n                _currentScene.setupTempCameraInit(activeNode, \"outSfMData\")\n            }\n            function close() {\n                _currentScene.clearTempCameraInit()\n            }\n        }\n\n        MaterialToolButton {\n            id: imageProcessing\n            Layout.minimumWidth: childrenRect.width\n\n            property var activeNode: _currentScene ? _currentScene.activeNodes.get(\"ImageProcessing\").node : null\n            font.pointSize: 15\n            padding: 0\n            ToolTip.text: \"Preprocessed Images: \" + (activeNode ? activeNode.label : \"No Node\")\n            text: MaterialIcons.wallpaper\n            visible: activeNode && activeNode.attribute(\"outSfMData\").value\n            enabled: activeNode && activeNode.isComputed\n            property string nodeID: activeNode ? (activeNode.label + activeNode.isComputed) : \"\"\n            onNodeIDChanged: {\n                if (checked) {\n                    open()\n                }\n            }\n            onEnabledChanged: {\n                // Reset the toggle to avoid getting stuck with the HDR node checked but disabled\n                if (checked) {\n                    checked = false\n                    close()\n                }\n            }\n            checkable: true\n            checked: false\n            onClicked: {\n                if (checked) {\n                    open()\n                } else {\n                    close()\n                }\n            }\n            function open() {\n                if (displayHDR.checked)\n                    displayHDR.checked = false\n                _currentScene.setupTempCameraInit(activeNode, \"outSfMData\")\n            }\n            function close() {\n                _currentScene.clearTempCameraInit()\n            }\n        }\n\n        Item {\n            Layout.fillHeight: true\n            width: 1\n        }\n\n        // Thumbnail size icon and slider\n        MaterialToolButton {\n            Layout.minimumWidth: childrenRect.width\n\n            text: MaterialIcons.photo_size_select_large\n            ToolTip.text: \"Thumbnails Scale\"\n            padding: 0\n            anchors.margins: 0\n            font.pointSize: 11\n            onClicked: { thumbnailSizeSlider.value = defaultCellSize }\n        }\n        Slider {\n            id: thumbnailSizeSlider\n            from: 70\n            value: defaultCellSize\n            to: 250\n            implicitWidth: 70\n        }\n    }\n\n    MessageDialog {\n        id: errorDialog\n\n        icon.text: MaterialIcons.error\n        icon.color: \"#F44336\"\n\n        title: \"Different File Types\"\n        text: \"Do not mix .mg files and other types of files.\"\n        standardButtons: Dialog.Ok\n\n        parent: Overlay.overlay\n\n        onAccepted: close()\n    }\n}\n"
  },
  {
    "path": "meshroom/ui/qml/ImageGallery/ImageGridView.qml",
    "content": "import QtQuick\nimport QtQuick.Controls\nimport QtQuick.Layouts\nimport QtQml.Models\nimport Qt.labs.qmlmodels\n\nimport Controls 1.0\nimport MaterialIcons 2.2\nimport Utils 1.0\n\nGridView {\n    id: root\n\n    // Exposed properties from ImageGallery\n    property var m: null\n    property var gallery: null\n    property var searchBar: null\n    property var thumbnailSizeSlider: null\n    property var intrinsicsFilterButton: null\n    property var tempCameraInit: null\n    property var errorDialog: null\n    property var sortedModel: null\n    \n    // Signals\n    signal removeImageRequest(var attribute)\n    signal allViewpointsCleared()\n\n    ScrollBar.vertical: MScrollBar {\n        active: true\n    }\n\n    focus: true\n    clip: true\n    cellWidth: thumbnailSizeSlider ? thumbnailSizeSlider.value : 160\n    cellHeight: cellWidth\n    highlightFollowsCurrentItem: true\n    keyNavigationEnabled: true\n    highlightMoveDuration: 0\n\n    // Update grid current item when selected view changes\n    Connections {\n        target: _currentScene\n        function onSelectedViewIdChanged() {\n            if (_currentScene.selectedViewId > -1) {\n                root.updateCurrentIndexFromSelectionViewId()\n            }\n        }\n    }\n    \n    function makeCurrentItemVisible() {\n        root.positionViewAtIndex(root.currentIndex, GridView.Visible)\n    }\n\n    function updateCurrentIndexFromSelectionViewId() {\n        if (!sortedModel) return\n        var idx = sortedModel.find(_currentScene.selectedViewId, \"viewId\")\n        if (idx >= 0 && root.currentIndex !== idx) {\n            root.currentIndex = idx\n        }\n    }\n    \n    onCurrentItemChanged: {\n        if (root.currentItem) {\n            if (tempCameraInit !== null && root.currentIndex == 0)\n                _currentScene.selectedViewId = -1\n            _currentScene.selectedViewId = root.currentItem.viewpoint.get(\"viewId\").value\n        }\n    }\n\n    // Update grid item when corresponding thumbnail is computed\n    Connections {\n        target: ThumbnailCache\n        function onThumbnailCreated(imgSource, callerID) {\n            let item = root.itemAtIndex(callerID);\n            if (item && item.source === imgSource) {\n                item.updateThumbnail()\n                return\n            }\n            for (let idx = 0; idx < root.count; idx++) {\n                item = root.itemAtIndex(idx)\n                if (item && item.source === imgSource) {\n                    item.updateThumbnail()\n                }\n            }\n        }\n    }\n\n    model: sortedModel\n\n    // Keyboard shortcut to change current image group\n    Keys.priority: Keys.BeforeItem\n    Keys.onPressed: function(event) {\n        if (event.modifiers & Qt.AltModifier) {\n            if (event.key === Qt.Key_Right && gallery && gallery.cameraInits) {\n                _currentScene.cameraInitIndex = Math.min(gallery.cameraInits.count - 1, gallery.cameraInitIndex + 1)\n                event.accepted = true\n            } else if (event.key === Qt.Key_Left) {\n                _currentScene.cameraInitIndex = Math.max(0, gallery.cameraInitIndex - 1)\n                event.accepted = true\n            }\n        } else {\n            if (event.key === Qt.Key_Right) {\n                root.moveCurrentIndexRight()\n                event.accepted = true\n            } else if (event.key === Qt.Key_Left) {\n                root.moveCurrentIndexLeft()\n                event.accepted = true\n            } else if (event.key === Qt.Key_Up) {\n                root.moveCurrentIndexUp()\n                event.accepted = true\n            } else if (event.key === Qt.Key_Down) {\n                root.moveCurrentIndexDown()\n                event.accepted = true\n            } else if (event.key === Qt.Key_Tab) {\n                if (searchBar)\n                    searchBar.forceActiveFocus()\n                event.accepted = true\n            }\n        }\n    }\n\n    // Explanatory placeholder when no image has been added yet\n    Column {\n        id: dropImagePlaceholder\n        anchors.centerIn: parent\n        visible: (m && m.viewpoints ? m.viewpoints.count === 0 : true) && (!intrinsicsFilterButton || !intrinsicsFilterButton.checked)\n        spacing: 4\n        Label {\n            anchors.horizontalCenter: parent.horizontalCenter\n            text: MaterialIcons.photo_library\n            font.pointSize: 24\n            font.family: MaterialIcons.fontFamily\n        }\n        Label {\n            text: \"Drop Image Files / Folders\"\n        }\n    }\n    \n    // Placeholder when the filtered images list is empty\n    Column {\n        id: noImageImagePlaceholder\n        anchors.centerIn: parent\n        visible: (m && m.viewpoints ? m.viewpoints.count !== 0 : false) && !dropImagePlaceholder.visible && root.count === 0 && (!intrinsicsFilterButton || !intrinsicsFilterButton.checked)\n        spacing: 4\n        Label {\n            anchors.horizontalCenter: parent.horizontalCenter\n            text: MaterialIcons.filter_none\n            font.pointSize: 24\n            font.family: MaterialIcons.fontFamily\n        }\n        Label {\n            text: \"No images in this filtered view\"\n        }\n    }\n\n    DropArea {\n        id: dropArea\n        anchors.fill: parent\n        enabled: m && !m.readOnly && (!intrinsicsFilterButton || !intrinsicsFilterButton.checked)\n        keys: [\"text/uri-list\"]\n        \n        property int nbDraggedFiles: 0\n        property var filesByType: ({})\n        property int nbMeshroomScenes: 0\n        \n        onEntered: function(drag) {\n            nbDraggedFiles = drag.urls.length\n            filesByType = _currentScene.getFilesByTypeFromDrop(drag.urls)\n            nbMeshroomScenes = filesByType[\"meshroomScenes\"].length\n        }\n        onDropped: function(drop) {\n            if (nbMeshroomScenes === nbDraggedFiles || nbMeshroomScenes === 0) {\n                if (gallery)\n                    gallery.filesDropped(filesByType)\n            } else {\n                if (errorDialog)\n                    errorDialog.open()\n            }\n        }\n\n        // Background opacifier\n        Rectangle {\n            visible: dropArea.containsDrag\n            anchors.fill: parent\n            color: gallery ? gallery.palette.window : palette.window\n            opacity: 0.8\n        }\n\n        Label {\n            id: addArea\n            anchors.fill: parent\n            visible: dropArea.containsDrag\n            horizontalAlignment: Text.AlignHCenter\n            verticalAlignment: Text.AlignVCenter\n            text: {\n                if (dropArea.nbMeshroomScenes != dropArea.nbDraggedFiles && dropArea.nbMeshroomScenes != 0) {\n                    return \"Cannot Add Projects And Images Together\"\n                }\n\n                if (dropArea.nbMeshroomScenes == 1 && dropArea.nbMeshroomScenes == dropArea.nbDraggedFiles) {\n                    return \"Load Project\"\n                } else if (dropArea.nbMeshroomScenes == dropArea.nbDraggedFiles) {\n                    return \"Only One Project\"\n                } else {\n                    return \"Add Images\"\n                }\n            }\n            font.bold: true\n            background: Rectangle {\n                color: dropArea.containsDrag ? parent.palette.highlight : parent.palette.window\n                opacity: 0.8\n                border.color: parent.palette.highlight\n            }\n        }\n    }\n\n    MouseArea {\n        anchors.fill: parent\n        onPressed: function(mouse) {\n            if (mouse.button == Qt.LeftButton)\n                root.forceActiveFocus()\n            mouse.accepted = false\n        }\n    }\n}"
  },
  {
    "path": "meshroom/ui/qml/ImageGallery/ImageListView.qml",
    "content": "import QtQuick\nimport QtQuick.Controls\nimport QtQuick.Layouts\nimport QtQml.Models\nimport Qt.labs.qmlmodels\n\nimport Controls 1.0\nimport MaterialIcons 2.2\nimport Utils 1.0\n\nListView {\n    id: root\n\n    // Exposed properties from ImageGallery\n    property var m: null\n    property var gallery: null\n    property var searchBar: null\n    property var thumbnailSizeSlider: null\n    property var intrinsicsFilterButton: null\n    property var tempCameraInit: null\n    property var errorDialog: null\n    property var sortedModel: null\n\n    property real cellHeight: thumbnailSizeSlider ? thumbnailSizeSlider.value / 2 : 80\n\n    // Signals\n    signal removeImageRequest(var attribute)\n    signal allViewpointsCleared()\n\n    ScrollBar.vertical: MScrollBar {\n        active: true\n    }\n\n    focus: true\n    clip: true\n    spacing: 2\n    highlightFollowsCurrentItem: true\n    keyNavigationEnabled: true\n    highlightMoveDuration: 0\n\n    // Update list current item when selected view changes\n    Connections {\n        target: _currentScene\n        function onSelectedViewIdChanged() {\n            if (_currentScene.selectedViewId > -1) {\n                root.updateCurrentIndexFromSelectionViewId()\n            }\n        }\n    }\n    \n    function makeCurrentItemVisible() {\n        root.positionViewAtIndex(root.currentIndex, ListView.Visible)\n    }\n\n    function updateCurrentIndexFromSelectionViewId() {\n        if (!sortedModel) return\n        var idx = sortedModel.find(_currentScene.selectedViewId, \"viewId\")\n        if (idx >= 0 && root.currentIndex !== idx) {\n            root.currentIndex = idx\n        }\n    }\n    \n    onCurrentItemChanged: {\n        if (root.currentItem) {\n            if (tempCameraInit !== null && root.currentIndex == 0)\n                _currentScene.selectedViewId = -1\n            _currentScene.selectedViewId = root.currentItem.viewpoint.get(\"viewId\").value\n        }\n    }\n\n    // Update list item when corresponding thumbnail is computed\n    Connections {\n        target: ThumbnailCache\n        function onThumbnailCreated(imgSource, callerID) {\n            let item = root.itemAtIndex(callerID);\n            if (item && item.source === imgSource) {\n                item.updateThumbnail()\n                return\n            }\n            for (let idx = 0; idx < root.count; idx++) {\n                item = root.itemAtIndex(idx)\n                if (item && item.source === imgSource) {\n                    item.updateThumbnail()\n                }\n            }\n        }\n    }\n\n    model: sortedModel\n\n    // Keyboard shortcut to change current image group\n    Keys.priority: Keys.BeforeItem\n    Keys.onPressed: function(event) {\n        if (event.modifiers & Qt.AltModifier) {\n            if (event.key === Qt.Key_Right && gallery && gallery.cameraInits) {\n                _currentScene.cameraInitIndex = Math.min(gallery.cameraInits.count - 1, gallery.cameraInitIndex + 1)\n                event.accepted = true\n            } else if (event.key === Qt.Key_Left) {\n                _currentScene.cameraInitIndex = Math.max(0, gallery.cameraInitIndex - 1)\n                event.accepted = true\n            }\n        } else {\n            if (event.key === Qt.Key_Down) {\n                root.incrementCurrentIndex()\n                event.accepted = true\n            } else if (event.key === Qt.Key_Up) {\n                root.decrementCurrentIndex()\n                event.accepted = true\n            } else if (event.key === Qt.Key_Tab) {\n                if (searchBar)\n                    searchBar.forceActiveFocus()\n                event.accepted = true\n            }\n        }\n    }\n\n    // Explanatory placeholder when no image has been added yet\n    Column {\n        id: dropImagePlaceholder\n        anchors.centerIn: parent\n        visible: (m && m.viewpoints ? m.viewpoints.count === 0 : true) && (!intrinsicsFilterButton || !intrinsicsFilterButton.checked)\n        spacing: 4\n        Label {\n            anchors.horizontalCenter: parent.horizontalCenter\n            text: MaterialIcons.photo_library\n            font.pointSize: 24\n            font.family: MaterialIcons.fontFamily\n        }\n        Label {\n            text: \"Drop Image Files / Folders\"\n        }\n    }\n    \n    // Placeholder when the filtered images list is empty\n    Column {\n        id: noImageImagePlaceholder\n        anchors.centerIn: parent\n        visible: (m && m.viewpoints ? m.viewpoints.count !== 0 : false) && !dropImagePlaceholder.visible && root.count === 0 && (!intrinsicsFilterButton || !intrinsicsFilterButton.checked)\n        spacing: 4\n        Label {\n            anchors.horizontalCenter: parent.horizontalCenter\n            text: MaterialIcons.filter_none\n            font.pointSize: 24\n            font.family: MaterialIcons.fontFamily\n        }\n        Label {\n            text: \"No images in this filtered view\"\n        }\n    }\n\n    DropArea {\n        id: dropArea\n        anchors.fill: parent\n        enabled: m && !m.readOnly && (!intrinsicsFilterButton || !intrinsicsFilterButton.checked)\n        keys: [\"text/uri-list\"]\n        \n        property int nbDraggedFiles: 0\n        property var filesByType: ({})\n        property int nbMeshroomScenes: 0\n        \n        onEntered: function(drag) {\n            nbDraggedFiles = drag.urls.length\n            filesByType = _currentScene.getFilesByTypeFromDrop(drag.urls)\n            nbMeshroomScenes = filesByType[\"meshroomScenes\"].length\n        }\n        onDropped: function(drop) {\n            if (nbMeshroomScenes == nbDraggedFiles || nbMeshroomScenes == 0) {\n                if (gallery)\n                    gallery.filesDropped(filesByType)\n            } else {\n                if (errorDialog)\n                    errorDialog.open()\n            }\n        }\n\n        // Background opacifier\n        Rectangle {\n            visible: dropArea.containsDrag\n            anchors.fill: parent\n            color: gallery ? gallery.palette.window : palette.window\n            opacity: 0.8\n        }\n\n        Label {\n            id: addArea\n            anchors.fill: parent\n            visible: dropArea.containsDrag\n            horizontalAlignment: Text.AlignHCenter\n            verticalAlignment: Text.AlignVCenter\n            text: {\n                if (dropArea.nbMeshroomScenes != dropArea.nbDraggedFiles && dropArea.nbMeshroomScenes != 0) {\n                    return \"Cannot Add Projects And Images Together\"\n                }\n\n                if (dropArea.nbMeshroomScenes == 1 && dropArea.nbMeshroomScenes == dropArea.nbDraggedFiles) {\n                    return \"Load Project\"\n                } else if (dropArea.nbMeshroomScenes == dropArea.nbDraggedFiles) {\n                    return \"Only One Project\"\n                } else {\n                    return \"Add Images\"\n                }\n            }\n            font.bold: true\n            background: Rectangle {\n                color: dropArea.containsDrag ? parent.palette.highlight : parent.palette.window\n                opacity: 0.8\n                border.color: parent.palette.highlight\n            }\n        }\n    }\n\n    MouseArea {\n        anchors.fill: parent\n        onPressed: function(mouse) {\n            if (mouse.button == Qt.LeftButton)\n                root.forceActiveFocus()\n            mouse.accepted = false\n        }\n    }\n}"
  },
  {
    "path": "meshroom/ui/qml/ImageGallery/IntrinsicDisplayDelegate.qml",
    "content": "import QtQuick\nimport QtQuick.Layouts\nimport QtQuick.Controls\n\nRowLayout {\n    id: root\n\n    Layout.fillWidth: true\n\n    property variant attribute: null\n    property int rowIndex: model.row\n    property int columnIndex: model.column\n    property bool readOnly: false\n    property string toolTipText: {\n        if (!attribute || attribute.label === undefined)\n            return \"\"\n        return attribute.label\n    }\n\n    Pane {\n        Layout.minimumWidth: loaderComponent.width\n        Layout.minimumHeight: loaderComponent.height\n        Layout.fillWidth: true\n\n        padding: 0\n\n        hoverEnabled: true\n\n        // Tooltip to replace headers for now (header incompatible atm)\n        ToolTip.delay: 10\n        ToolTip.timeout: 5000\n        ToolTip.visible: hovered\n        ToolTip.text: toolTipText\n\n        Rectangle {\n            width: parent.width\n            height: loaderComponent.height\n\n            color: rowIndex % 2 ? palette.window : Qt.darker(palette.window, 1.1)\n            border.width: 2\n            border.color: Qt.darker(palette.window, 1.2)\n            clip: true\n            Loader {\n                id: loaderComponent\n                active: !!attribute // convert to bool with \"!!\"\n                sourceComponent: {\n                    if (!attribute)\n                        return undefined\n                    switch (attribute.type) {\n                       case \"ChoiceParam\": return choiceComponent\n                       case \"IntParam\": return intComponent\n                       case \"FloatParam\": return floatComponent\n                       case \"BoolParam\": return boolComponent\n                       case \"StringParam\": return textFieldComponent\n                       case \"File\": return textFieldComponent\n                       default: return undefined\n                    }\n                }\n            }\n        }\n    }\n\n    Component {\n        id: textFieldComponent\n        TextInput {\n            text: attribute.value\n            width: intrinsicModel.columnWidths[columnIndex]\n            horizontalAlignment: TextInput.AlignRight\n            readOnly: root.readOnly\n            color: palette.text\n\n            padding: 12\n\n            selectByMouse: true\n            selectionColor: palette.text\n            selectedTextColor: Qt.darker(palette.window, 1.1)\n\n            onEditingFinished: _currentScene.setAttribute(attribute, text)\n            onAccepted: {\n                _currentScene.setAttribute(attribute, text)\n            }\n            Component.onDestruction: {\n                if (activeFocus)\n                    _currentScene.setAttribute(attribute, text)\n            }\n        }\n    }\n\n    Component {\n        id: intComponent\n\n        TextInput {\n            text: model.display.value\n            width: intrinsicModel.columnWidths[columnIndex]\n            horizontalAlignment: TextInput.AlignRight\n            color: palette.text\n            readOnly: root.readOnly\n\n            padding: 12\n\n            selectByMouse: true\n            selectionColor: palette.text\n            selectedTextColor: Qt.darker(palette.window, 1.1)\n\n            IntValidator {\n                id: intValidator\n            }\n\n            validator: intValidator\n\n            onEditingFinished: _currentScene.setAttribute(attribute, Number(text))\n            onAccepted: {\n                _currentScene.setAttribute(attribute, Number(text))\n            }\n            Component.onDestruction: {\n                if (activeFocus)\n                    _currentScene.setAttribute(attribute, Number(text))\n            }\n        }\n    }\n\n    Component {\n        id: choiceComponent\n        ComboBox {\n            id: combo\n            model: attribute.desc !== undefined ? attribute.desc.values : undefined\n            width: intrinsicModel.columnWidths[columnIndex]\n            enabled: !root.readOnly\n\n            flat : true\n\n            topInset: 7\n            leftInset: 6\n            rightInset: 6\n            bottomInset: 7\n\n            Component.onCompleted: currentIndex = find(attribute.value)\n            onActivated: _currentScene.setAttribute(attribute, currentText)\n\n            Connections {\n                target: attribute\n                function onValueChanged() { combo.currentIndex = combo.find(attribute.value) }\n            }\n        }\n    }\n\n    Component {\n        id: boolComponent\n        CheckBox {\n            checked: attribute ? attribute.value : false\n            padding: 12\n            enabled: !readOnly\n            onToggled: _currentScene.setAttribute(attribute, !attribute.value)\n        }\n    }\n\n    Component {\n        id: floatComponent\n        TextInput {\n            readonly property real formattedValue: attribute.value.toFixed(2)\n            property string displayValue: String(formattedValue)\n\n            text: displayValue\n            width: intrinsicModel.columnWidths[columnIndex]\n            horizontalAlignment: TextInput.AlignRight\n\n            color: palette.text\n            padding: 12\n\n            selectByMouse: true\n            selectionColor: palette.text\n            selectedTextColor: Qt.darker(palette.window, 1.1)\n\n            readOnly: root.readOnly\n            enabled: !readOnly\n\n            clip: true\n\n            autoScroll: activeFocus\n\n            // Use this function to ensure the left part is visible\n            // while keeping the trick for formatting the text\n            // Timing issues otherwise\n            onActiveFocusChanged: {\n                if (activeFocus)\n                    text = String(attribute.value)\n                else\n                    text = String(formattedValue)\n                cursorPosition = 0\n            }\n\n            DoubleValidator {\n                id: doubleValidator\n                locale: 'C'  // Use '.' decimal separator disregarding the system locale\n            }\n\n            validator: doubleValidator\n\n            onEditingFinished: _currentScene.setAttribute(attribute, Number(text))\n            onAccepted: {\n                _currentScene.setAttribute(attribute, Number(text))\n            }\n            Component.onDestruction: {\n                if (activeFocus)\n                    _currentScene.setAttribute(attribute, Number(text))\n            }\n        }\n    }\n}\n"
  },
  {
    "path": "meshroom/ui/qml/ImageGallery/IntrinsicsIndicator.qml",
    "content": "import QtQuick\nimport QtQuick.Controls\n\nimport MaterialIcons 2.2\nimport Utils 1.0\n\n/**\n * Display camera initialization status and the value of metadata\n * that take part in this process.\n */\n\nImageBadge {\n    id: root\n\n    // Intrinsic GroupAttribute\n    property var intrinsic: null\n\n    readonly property string intrinsicInitMode: intrinsic ? childAttributeValue(intrinsic, \"initializationMode\", \"none\") : \"unknown\"\n    readonly property string distortionInitMode: intrinsic ? childAttributeValue(intrinsic, \"distortionInitializationMode\", \"none\") : \"unknown\"\n    readonly property string distortionModel: intrinsic ? childAttributeValue(intrinsic, \"type\", \"\") : \"\"\n    property var metadata: ({})\n\n    function findMetadata(key) {\n        var keyLower = key.toLowerCase()\n        for (var mKey in metadata) {\n            if (mKey.toLowerCase().endsWith(keyLower))\n                return metadata[mKey]\n        }\n        return \"\"\n    }\n\n    // Access useful metadata\n    readonly property var make: findMetadata(\"Make\")\n    readonly property var model: findMetadata(\"Model\")\n    readonly property var focalLength: findMetadata(\"FocalLength\")\n    readonly property var focalLength35: findMetadata(\"FocalLengthIn35mmFilm\")\n    readonly property var bodySerialNumber: findMetadata(\"BodySerialNumber\")\n    readonly property var lensSerialNumber: findMetadata(\"LensSerialNumber\")\n    readonly property var sensorWidth: metadata[\"AliceVision:SensorWidth\"]\n    readonly property var sensorWidthEstimation: metadata[\"AliceVision:SensorWidthEstimation\"]\n\n    property string statusText: \"\"\n    property string detailsText: \"\"\n    property string helperText: \"\"\n\n    text: MaterialIcons.camera\n    \n    function childAttributeValue(attribute, childName, defaultValue) {\n        var attr = attribute.childAttribute(childName);\n        return attr ? attr.value : defaultValue;\n    }\n\n    function metaStr(value) {\n        return value || \"<i>undefined</i>\"\n    }\n\n    ToolTip.text: \"<b>Camera Intrinsics: \" + statusText + \"</b><br>\"\n                  + (detailsText ? detailsText + \"<br>\" : \"\")\n                  + (helperText ? helperText + \"<br>\" : \"\")\n                  + \"<br>\"\n                  + \"<b>Distortion: \" + distortionInitMode + \"</b><br>\"\n                  + (distortionModel ? 'Distortion Model: ' + distortionModel + \"<br>\" : \"\")\n                  + \"<br>\"\n                  + \"[Metadata]<br>\"\n                  + \" - Make: \" + metaStr(make) + \"<br>\"\n                  + \" - Model: \" + metaStr(model) + \"<br>\"\n                  + \" - FocalLength: \" + metaStr(focalLength) + \"<br>\"\n                  + ((focalLength && sensorWidth) ? \"\" : \" - FocalLengthIn35mmFilm: \" + metaStr(focalLength35) + \"<br>\")\n                  + \" - SensorWidth: \" + metaStr(sensorWidth) + (sensorWidthEstimation ? \" (estimation: \"+ sensorWidthEstimation + \")\" : \"\")\n                  + ((bodySerialNumber || lensSerialNumber) ? \"\" : \"<br><br>Warning: SerialNumber metadata is missing.<br> Images from different devices might incorrectly share the same camera internal settings.\")\n\n\n    state: intrinsicInitMode\n\n    states: [\n        State {\n            name: \"calibrated\"\n            PropertyChanges {\n                target: root\n                color: Colors.green\n                statusText: \"Calibrated\"\n                detailsText: \"Focal Length has been initialized externally.\"\n            }\n        },\n        State {\n            name: \"estimated\"\n            PropertyChanges {\n                target: root\n                statusText: sensorWidth ? \"Estimated\" : \"Approximated\"\n                color: sensorWidth ? Colors.green : Colors.yellow\n                detailsText: \"Focal Length was estimated from Metadata\" + (sensorWidth ? \" and Sensor Database.\" : \" only.\")\n                helperText: !sensorWidth ? \"Add your Camera Model to the Sensor Database for more accurate results.\" : \"\"\n            }\n        },\n        State {\n            name: \"unknown\"\n            PropertyChanges {\n                target: root\n                color: focalLength ? Colors.orange : Colors.red\n                statusText: \"Unknown\"\n                detailsText: \"Focal Length could not be determined from metadata. <br>\"\n                            + \"The default Field of View value was used as a fallback, which may lead to inaccurate result or failure.\"\n                helperText: \"Check for missing Image metadata\"\n                            + (make && model && !sensorWidth ? \" and/or add your Camera Model to the Sensor Database.\" : \".\")\n            }\n        },\n        State {\n            // Fallback status when initialization mode is unset\n            name: \"none\"\n            PropertyChanges {\n                target: root\n                visible: false\n            }\n        }\n    ]\n}\n"
  },
  {
    "path": "meshroom/ui/qml/ImageGallery/SensorDBDialog.qml",
    "content": "import QtQuick\nimport QtQuick.Controls\nimport QtQuick.Layouts\n\nimport MaterialIcons 2.2\nimport Controls 1.0\n\nMessageDialog {\n    id: root\n\n    property url sensorDatabase\n    property bool readOnly: false\n\n    signal updateIntrinsicsRequest()\n\n    icon.text: MaterialIcons.camera\n    icon.font.pointSize: 10\n\n    parent: Overlay.overlay\n    canCopy: false\n\n    title: \"Sensor Database\"\n    text: \"Add missing Camera Models to the Sensor Database to improve your results.\"\n    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.\"\n    helperText: 'To update the Sensor Database (<a href=\"https://github.com/alicevision/meshroom/wiki/Add-Camera-to-database\">complete guide</a>):<br>' +\n                ' - Look for the \"sensor width\" in millimeters of your Camera Model<br>' +\n                ' - Add a new line in the Database following this pattern: Make;Model;SensorWidthInMM<br>' +\n                ' - Click on \"Update Intrinsics\" once the Database has been saved<br>' +\n                ' - Contribute to the <a href=\"https://github.com/alicevision/AliceVision/blob/develop/src/aliceVision/sensorDB/cameraSensors.db\">online Database</a>'\n\n    content: ColumnLayout {\n        RowLayout {\n            Layout.fillWidth: true\n            spacing: 2\n\n            Label {\n                text: \"Sensor Database:\"\n            }\n\n            TextField {\n                id: sensorDBTextField\n                Layout.fillWidth: true\n                text: Filepath.normpath(sensorDatabase)\n                selectByMouse: true\n                readOnly: true\n            }\n            MaterialToolButton {\n                text: MaterialIcons.assignment\n                ToolTip.text: \"Copy Path\"\n                onClicked: {\n                    sensorDBTextField.selectAll();\n                    sensorDBTextField.copy();\n                    ToolTip.text = \"Path has been copied!\"\n                }\n                onHoveredChanged: if(!hovered) ToolTip.text = \"Copy Path\"\n            }\n            MaterialToolButton {\n                text: MaterialIcons.open_in_new\n                ToolTip.text: \"Open in External Editor\"\n                onClicked: Qt.openUrlExternally(sensorDatabase)\n            }\n        }\n\n        Button {\n            id: rebuildIntrinsics\n            text: \"Update Intrinsics\"\n            enabled: !readOnly\n            onClicked: updateIntrinsicsRequest()\n            Layout.alignment: Qt.AlignCenter\n        }\n    }\n    standardButtons: Dialog.Close\n    onAccepted: close()\n}\n"
  },
  {
    "path": "meshroom/ui/qml/ImageGallery/qmldir",
    "content": "module ImageGallery\n\nImageGallery 1.0 ImageGallery.qml\nImageDelegate 1.0 ImageDelegate.qml\nImageGridView 1.0 ImageGridView.qml\nImageListView 1.0 ImageListView.qml\n\nImageIntrinsicDelegate 1.0 ImageIntrinsicDelegate.qml\nImageIntrinsicViewer 1.0 ImageIntrinsicViewer.qml\nIntrinsicDisplayDelegate 1.0 IntrinsicDisplayDelegate.qml\n"
  },
  {
    "path": "meshroom/ui/qml/MaterialIcons/MLabel.qml",
    "content": "import QtQuick\nimport QtQuick.Controls\n\n/**\n * MLabel is a standard Label.\n * If ToolTip.text is set, it shows up a tooltip when hovered.\n */\n\nLabel {\n    padding: 4\n    MouseArea {\n        id: mouseArea\n        anchors.fill: parent\n        hoverEnabled: true\n        acceptedButtons: Qt.NoButton\n    }\n    ToolTip.visible: mouseArea.containsMouse\n    ToolTip.delay: 500\n    background: Rectangle {\n        anchors.fill: parent\n        color: mouseArea.containsMouse ? Qt.darker(parent.palette.base, 0.6) : \"transparent\"\n    }\n}\n"
  },
  {
    "path": "meshroom/ui/qml/MaterialIcons/MaterialIcons.qml",
    "content": "pragma Singleton\nimport QtQuick\n\nQtObject {\n    property FontLoader fl: FontLoader {\n        source: \"./MaterialIcons-Regular.ttf\"\n    }\n    readonly property string fontFamily: fl.name\n\n    readonly property string _10k: \"\\ue951\"\n    readonly property string _10mp: \"\\ue952\"\n    readonly property string _11mp: \"\\ue953\"\n    readonly property string _123: \"\\ueb8d\"\n    readonly property string _12mp: \"\\ue954\"\n    readonly property string _13mp: \"\\ue955\"\n    readonly property string _14mp: \"\\ue956\"\n    readonly property string _15mp: \"\\ue957\"\n    readonly property string _16mp: \"\\ue958\"\n    readonly property string _17mp: \"\\ue959\"\n    readonly property string _18_up_rating: \"\\uf8fd\"\n    readonly property string _18mp: \"\\ue95a\"\n    readonly property string _19mp: \"\\ue95b\"\n    readonly property string _1k: \"\\ue95c\"\n    readonly property string _1k_plus: \"\\ue95d\"\n    readonly property string _1x_mobiledata: \"\\uefcd\"\n    readonly property string _20mp: \"\\ue95e\"\n    readonly property string _21mp: \"\\ue95f\"\n    readonly property string _22mp: \"\\ue960\"\n    readonly property string _23mp: \"\\ue961\"\n    readonly property string _24mp: \"\\ue962\"\n    readonly property string _2k: \"\\ue963\"\n    readonly property string _2k_plus: \"\\ue964\"\n    readonly property string _2mp: \"\\ue965\"\n    readonly property string _30fps: \"\\uefce\"\n    readonly property string _30fps_select: \"\\uefcf\"\n    readonly property string _360: \"\\ue577\"\n    readonly property string _3d_rotation: \"\\ue84d\"\n    readonly property string _3g_mobiledata: \"\\uefd0\"\n    readonly property string _3k: \"\\ue966\"\n    readonly property string _3k_plus: \"\\ue967\"\n    readonly property string _3mp: \"\\ue968\"\n    readonly property string _3p: \"\\uefd1\"\n    readonly property string _4g_mobiledata: \"\\uefd2\"\n    readonly property string _4g_plus_mobiledata: \"\\uefd3\"\n    readonly property string _4k: \"\\ue072\"\n    readonly property string _4k_plus: \"\\ue969\"\n    readonly property string _4mp: \"\\ue96a\"\n    readonly property string _5g: \"\\uef38\"\n    readonly property string _5k: \"\\ue96b\"\n    readonly property string _5k_plus: \"\\ue96c\"\n    readonly property string _5mp: \"\\ue96d\"\n    readonly property string _60fps: \"\\uefd4\"\n    readonly property string _60fps_select: \"\\uefd5\"\n    readonly property string _6_ft_apart: \"\\uf21e\"\n    readonly property string _6k: \"\\ue96e\"\n    readonly property string _6k_plus: \"\\ue96f\"\n    readonly property string _6mp: \"\\ue970\"\n    readonly property string _7k: \"\\ue971\"\n    readonly property string _7k_plus: \"\\ue972\"\n    readonly property string _7mp: \"\\ue973\"\n    readonly property string _8k: \"\\ue974\"\n    readonly property string _8k_plus: \"\\ue975\"\n    readonly property string _8mp: \"\\ue976\"\n    readonly property string _9k: \"\\ue977\"\n    readonly property string _9k_plus: \"\\ue978\"\n    readonly property string _9mp: \"\\ue979\"\n    readonly property string abc: \"\\ueb94\"\n    readonly property string ac_unit: \"\\ueb3b\"\n    readonly property string access_alarm: \"\\ue190\"\n    readonly property string access_alarms: \"\\ue191\"\n    readonly property string access_time: \"\\ue192\"\n    readonly property string access_time_filled: \"\\uefd6\"\n    readonly property string accessibility: \"\\ue84e\"\n    readonly property string accessibility_new: \"\\ue92c\"\n    readonly property string accessible: \"\\ue914\"\n    readonly property string accessible_forward: \"\\ue934\"\n    readonly property string account_balance: \"\\ue84f\"\n    readonly property string account_balance_wallet: \"\\ue850\"\n    readonly property string account_box: \"\\ue851\"\n    readonly property string account_circle: \"\\ue853\"\n    readonly property string account_tree: \"\\ue97a\"\n    readonly property string ad_units: \"\\uef39\"\n    readonly property string adb: \"\\ue60e\"\n    readonly property string add: \"\\ue145\"\n    readonly property string add_a_photo: \"\\ue439\"\n    readonly property string add_alarm: \"\\ue193\"\n    readonly property string add_alert: \"\\ue003\"\n    readonly property string add_box: \"\\ue146\"\n    readonly property string add_business: \"\\ue729\"\n    readonly property string add_call: \"\\ue0e8\"\n    readonly property string add_card: \"\\ueb86\"\n    readonly property string add_chart: \"\\ue97b\"\n    readonly property string add_circle: \"\\ue147\"\n    readonly property string add_circle_outline: \"\\ue148\"\n    readonly property string add_comment: \"\\ue266\"\n    readonly property string add_home: \"\\uf8eb\"\n    readonly property string add_home_work: \"\\uf8ed\"\n    readonly property string add_ic_call: \"\\ue97c\"\n    readonly property string add_link: \"\\ue178\"\n    readonly property string add_location: \"\\ue567\"\n    readonly property string add_location_alt: \"\\uef3a\"\n    readonly property string add_moderator: \"\\ue97d\"\n    readonly property string add_photo_alternate: \"\\ue43e\"\n    readonly property string add_reaction: \"\\ue1d3\"\n    readonly property string add_road: \"\\uef3b\"\n    readonly property string add_shopping_cart: \"\\ue854\"\n    readonly property string add_task: \"\\uf23a\"\n    readonly property string add_to_drive: \"\\ue65c\"\n    readonly property string add_to_home_screen: \"\\ue1fe\"\n    readonly property string add_to_photos: \"\\ue39d\"\n    readonly property string add_to_queue: \"\\ue05c\"\n    readonly property string addchart: \"\\uef3c\"\n    readonly property string adf_scanner: \"\\ueada\"\n    readonly property string adjust: \"\\ue39e\"\n    readonly property string admin_panel_settings: \"\\uef3d\"\n    readonly property string adobe: \"\\uea96\"\n    readonly property string ads_click: \"\\ue762\"\n    readonly property string agriculture: \"\\uea79\"\n    readonly property string air: \"\\uefd8\"\n    readonly property string airline_seat_flat: \"\\ue630\"\n    readonly property string airline_seat_flat_angled: \"\\ue631\"\n    readonly property string airline_seat_individual_suite: \"\\ue632\"\n    readonly property string airline_seat_legroom_extra: \"\\ue633\"\n    readonly property string airline_seat_legroom_normal: \"\\ue634\"\n    readonly property string airline_seat_legroom_reduced: \"\\ue635\"\n    readonly property string airline_seat_recline_extra: \"\\ue636\"\n    readonly property string airline_seat_recline_normal: \"\\ue637\"\n    readonly property string airline_stops: \"\\ue7d0\"\n    readonly property string airlines: \"\\ue7ca\"\n    readonly property string airplane_ticket: \"\\uefd9\"\n    readonly property string airplanemode_active: \"\\ue195\"\n    readonly property string airplanemode_inactive: \"\\ue194\"\n    readonly property string airplanemode_off: \"\\ue194\"\n    readonly property string airplanemode_on: \"\\ue195\"\n    readonly property string airplay: \"\\ue055\"\n    readonly property string airport_shuttle: \"\\ueb3c\"\n    readonly property string alarm: \"\\ue855\"\n    readonly property string alarm_add: \"\\ue856\"\n    readonly property string alarm_off: \"\\ue857\"\n    readonly property string alarm_on: \"\\ue858\"\n    readonly property string album: \"\\ue019\"\n    readonly property string align_horizontal_center: \"\\ue00f\"\n    readonly property string align_horizontal_left: \"\\ue00d\"\n    readonly property string align_horizontal_right: \"\\ue010\"\n    readonly property string align_vertical_bottom: \"\\ue015\"\n    readonly property string align_vertical_center: \"\\ue011\"\n    readonly property string align_vertical_top: \"\\ue00c\"\n    readonly property string all_inbox: \"\\ue97f\"\n    readonly property string all_inclusive: \"\\ueb3d\"\n    readonly property string all_out: \"\\ue90b\"\n    readonly property string alt_route: \"\\uf184\"\n    readonly property string alternate_email: \"\\ue0e6\"\n    readonly property string amp_stories: \"\\uea13\"\n    readonly property string analytics: \"\\uef3e\"\n    readonly property string anchor: \"\\uf1cd\"\n    readonly property string android: \"\\ue859\"\n    readonly property string animation: \"\\ue71c\"\n    readonly property string announcement: \"\\ue85a\"\n    readonly property string aod: \"\\uefda\"\n    readonly property string apartment: \"\\uea40\"\n    readonly property string api: \"\\uf1b7\"\n    readonly property string app_blocking: \"\\uef3f\"\n    readonly property string app_registration: \"\\uef40\"\n    readonly property string app_settings_alt: \"\\uef41\"\n    readonly property string app_shortcut: \"\\ueae4\"\n    readonly property string apple: \"\\uea80\"\n    readonly property string approval: \"\\ue982\"\n    readonly property string apps: \"\\ue5c3\"\n    readonly property string apps_outage: \"\\ue7cc\"\n    readonly property string architecture: \"\\uea3b\"\n    readonly property string archive: \"\\ue149\"\n    readonly property string area_chart: \"\\ue770\"\n    readonly property string arrow_back: \"\\ue5c4\"\n    readonly property string arrow_back_ios: \"\\ue5e0\"\n    readonly property string arrow_back_ios_new: \"\\ue2ea\"\n    readonly property string arrow_circle_down: \"\\uf181\"\n    readonly property string arrow_circle_left: \"\\ueaa7\"\n    readonly property string arrow_circle_right: \"\\ueaaa\"\n    readonly property string arrow_circle_up: \"\\uf182\"\n    readonly property string arrow_downward: \"\\ue5db\"\n    readonly property string arrow_drop_down: \"\\ue5c5\"\n    readonly property string arrow_drop_down_circle: \"\\ue5c6\"\n    readonly property string arrow_drop_up: \"\\ue5c7\"\n    readonly property string arrow_forward: \"\\ue5c8\"\n    readonly property string arrow_forward_ios: \"\\ue5e1\"\n    readonly property string arrow_left: \"\\ue5de\"\n    readonly property string arrow_outward: \"\\uf8ce\"\n    readonly property string arrow_right: \"\\ue5df\"\n    readonly property string arrow_right_alt: \"\\ue941\"\n    readonly property string arrow_upward: \"\\ue5d8\"\n    readonly property string art_track: \"\\ue060\"\n    readonly property string article: \"\\uef42\"\n    readonly property string aspect_ratio: \"\\ue85b\"\n    readonly property string assessment: \"\\ue85c\"\n    readonly property string assignment: \"\\ue85d\"\n    readonly property string assignment_add: \"\\uf848\"\n    readonly property string assignment_ind: \"\\ue85e\"\n    readonly property string assignment_late: \"\\ue85f\"\n    readonly property string assignment_return: \"\\ue860\"\n    readonly property string assignment_returned: \"\\ue861\"\n    readonly property string assignment_turned_in: \"\\ue862\"\n    readonly property string assist_walker: \"\\uf8d5\"\n    readonly property string assistant: \"\\ue39f\"\n    readonly property string assistant_direction: \"\\ue988\"\n    readonly property string assistant_navigation: \"\\ue989\"\n    readonly property string assistant_photo: \"\\ue3a0\"\n    readonly property string assured_workload: \"\\ueb6f\"\n    readonly property string atm: \"\\ue573\"\n    readonly property string attach_email: \"\\uea5e\"\n    readonly property string attach_file: \"\\ue226\"\n    readonly property string attach_money: \"\\ue227\"\n    readonly property string attachment: \"\\ue2bc\"\n    readonly property string attractions: \"\\uea52\"\n    readonly property string attribution: \"\\uefdb\"\n    readonly property string audio_file: \"\\ueb82\"\n    readonly property string audiotrack: \"\\ue3a1\"\n    readonly property string auto_awesome: \"\\ue65f\"\n    readonly property string auto_awesome_mosaic: \"\\ue660\"\n    readonly property string auto_awesome_motion: \"\\ue661\"\n    readonly property string auto_delete: \"\\uea4c\"\n    readonly property string auto_fix_high: \"\\ue663\"\n    readonly property string auto_fix_normal: \"\\ue664\"\n    readonly property string auto_fix_off: \"\\ue665\"\n    readonly property string auto_graph: \"\\ue4fb\"\n    readonly property string auto_mode: \"\\uec20\"\n    readonly property string auto_stories: \"\\ue666\"\n    readonly property string autofps_select: \"\\uefdc\"\n    readonly property string autorenew: \"\\ue863\"\n    readonly property string av_timer: \"\\ue01b\"\n    readonly property string baby_changing_station: \"\\uf19b\"\n    readonly property string back_hand: \"\\ue764\"\n    readonly property string backpack: \"\\uf19c\"\n    readonly property string backspace: \"\\ue14a\"\n    readonly property string backup: \"\\ue864\"\n    readonly property string backup_table: \"\\uef43\"\n    readonly property string badge: \"\\uea67\"\n    readonly property string bakery_dining: \"\\uea53\"\n    readonly property string balance: \"\\ueaf6\"\n    readonly property string balcony: \"\\ue58f\"\n    readonly property string ballot: \"\\ue172\"\n    readonly property string bar_chart: \"\\ue26b\"\n    readonly property string barcode_reader: \"\\uf85c\"\n    readonly property string batch_prediction: \"\\uf0f5\"\n    readonly property string bathroom: \"\\uefdd\"\n    readonly property string bathtub: \"\\uea41\"\n    readonly property string battery_0_bar: \"\\uebdc\"\n    readonly property string battery_1_bar: \"\\uebd9\"\n    readonly property string battery_2_bar: \"\\uebe0\"\n    readonly property string battery_3_bar: \"\\uebdd\"\n    readonly property string battery_4_bar: \"\\uebe2\"\n    readonly property string battery_5_bar: \"\\uebd4\"\n    readonly property string battery_6_bar: \"\\uebd2\"\n    readonly property string battery_alert: \"\\ue19c\"\n    readonly property string battery_charging_full: \"\\ue1a3\"\n    readonly property string battery_full: \"\\ue1a4\"\n    readonly property string battery_saver: \"\\uefde\"\n    readonly property string battery_std: \"\\ue1a5\"\n    readonly property string battery_unknown: \"\\ue1a6\"\n    readonly property string beach_access: \"\\ueb3e\"\n    readonly property string bed: \"\\uefdf\"\n    readonly property string bedroom_baby: \"\\uefe0\"\n    readonly property string bedroom_child: \"\\uefe1\"\n    readonly property string bedroom_parent: \"\\uefe2\"\n    readonly property string bedtime: \"\\uef44\"\n    readonly property string bedtime_off: \"\\ueb76\"\n    readonly property string beenhere: \"\\ue52d\"\n    readonly property string bento: \"\\uf1f4\"\n    readonly property string bike_scooter: \"\\uef45\"\n    readonly property string biotech: \"\\uea3a\"\n    readonly property string blender: \"\\uefe3\"\n    readonly property string blind: \"\\uf8d6\"\n    readonly property string blinds: \"\\ue286\"\n    readonly property string blinds_closed: \"\\uec1f\"\n    readonly property string block: \"\\ue14b\"\n    readonly property string block_flipped: \"\\uef46\"\n    readonly property string bloodtype: \"\\uefe4\"\n    readonly property string bluetooth: \"\\ue1a7\"\n    readonly property string bluetooth_audio: \"\\ue60f\"\n    readonly property string bluetooth_connected: \"\\ue1a8\"\n    readonly property string bluetooth_disabled: \"\\ue1a9\"\n    readonly property string bluetooth_drive: \"\\uefe5\"\n    readonly property string bluetooth_searching: \"\\ue1aa\"\n    readonly property string blur_circular: \"\\ue3a2\"\n    readonly property string blur_linear: \"\\ue3a3\"\n    readonly property string blur_off: \"\\ue3a4\"\n    readonly property string blur_on: \"\\ue3a5\"\n    readonly property string bolt: \"\\uea0b\"\n    readonly property string book: \"\\ue865\"\n    readonly property string book_online: \"\\uf217\"\n    readonly property string bookmark: \"\\ue866\"\n    readonly property string bookmark_add: \"\\ue598\"\n    readonly property string bookmark_added: \"\\ue599\"\n    readonly property string bookmark_border: \"\\ue867\"\n    readonly property string bookmark_outline: \"\\ue867\"\n    readonly property string bookmark_remove: \"\\ue59a\"\n    readonly property string bookmarks: \"\\ue98b\"\n    readonly property string border_all: \"\\ue228\"\n    readonly property string border_bottom: \"\\ue229\"\n    readonly property string border_clear: \"\\ue22a\"\n    readonly property string border_color: \"\\ue22b\"\n    readonly property string border_horizontal: \"\\ue22c\"\n    readonly property string border_inner: \"\\ue22d\"\n    readonly property string border_left: \"\\ue22e\"\n    readonly property string border_outer: \"\\ue22f\"\n    readonly property string border_right: \"\\ue230\"\n    readonly property string border_style: \"\\ue231\"\n    readonly property string border_top: \"\\ue232\"\n    readonly property string border_vertical: \"\\ue233\"\n    readonly property string boy: \"\\ueb67\"\n    readonly property string branding_watermark: \"\\ue06b\"\n    readonly property string breakfast_dining: \"\\uea54\"\n    readonly property string brightness_1: \"\\ue3a6\"\n    readonly property string brightness_2: \"\\ue3a7\"\n    readonly property string brightness_3: \"\\ue3a8\"\n    readonly property string brightness_4: \"\\ue3a9\"\n    readonly property string brightness_5: \"\\ue3aa\"\n    readonly property string brightness_6: \"\\ue3ab\"\n    readonly property string brightness_7: \"\\ue3ac\"\n    readonly property string brightness_auto: \"\\ue1ab\"\n    readonly property string brightness_high: \"\\ue1ac\"\n    readonly property string brightness_low: \"\\ue1ad\"\n    readonly property string brightness_medium: \"\\ue1ae\"\n    readonly property string broadcast_on_home: \"\\uf8f8\"\n    readonly property string broadcast_on_personal: \"\\uf8f9\"\n    readonly property string broken_image: \"\\ue3ad\"\n    readonly property string browse_gallery: \"\\uebd1\"\n    readonly property string browser_not_supported: \"\\uef47\"\n    readonly property string browser_updated: \"\\ue7cf\"\n    readonly property string brunch_dining: \"\\uea73\"\n    readonly property string brush: \"\\ue3ae\"\n    readonly property string bubble_chart: \"\\ue6dd\"\n    readonly property string bug_report: \"\\ue868\"\n    readonly property string build: \"\\ue869\"\n    readonly property string build_circle: \"\\uef48\"\n    readonly property string bungalow: \"\\ue591\"\n    readonly property string burst_mode: \"\\ue43c\"\n    readonly property string bus_alert: \"\\ue98f\"\n    readonly property string business: \"\\ue0af\"\n    readonly property string business_center: \"\\ueb3f\"\n    readonly property string cabin: \"\\ue589\"\n    readonly property string cable: \"\\uefe6\"\n    readonly property string cached: \"\\ue86a\"\n    readonly property string cake: \"\\ue7e9\"\n    readonly property string calculate: \"\\uea5f\"\n    readonly property string calendar_month: \"\\uebcc\"\n    readonly property string calendar_today: \"\\ue935\"\n    readonly property string calendar_view_day: \"\\ue936\"\n    readonly property string calendar_view_month: \"\\uefe7\"\n    readonly property string calendar_view_week: \"\\uefe8\"\n    readonly property string call: \"\\ue0b0\"\n    readonly property string call_end: \"\\ue0b1\"\n    readonly property string call_made: \"\\ue0b2\"\n    readonly property string call_merge: \"\\ue0b3\"\n    readonly property string call_missed: \"\\ue0b4\"\n    readonly property string call_missed_outgoing: \"\\ue0e4\"\n    readonly property string call_received: \"\\ue0b5\"\n    readonly property string call_split: \"\\ue0b6\"\n    readonly property string call_to_action: \"\\ue06c\"\n    readonly property string camera: \"\\ue3af\"\n    readonly property string camera_alt: \"\\ue3b0\"\n    readonly property string camera_enhance: \"\\ue8fc\"\n    readonly property string camera_front: \"\\ue3b1\"\n    readonly property string camera_indoor: \"\\uefe9\"\n    readonly property string camera_outdoor: \"\\uefea\"\n    readonly property string camera_rear: \"\\ue3b2\"\n    readonly property string camera_roll: \"\\ue3b3\"\n    readonly property string cameraswitch: \"\\uefeb\"\n    readonly property string campaign: \"\\uef49\"\n    readonly property string cancel: \"\\ue5c9\"\n    readonly property string cancel_presentation: \"\\ue0e9\"\n    readonly property string cancel_schedule_send: \"\\uea39\"\n    readonly property string candlestick_chart: \"\\uead4\"\n    readonly property string car_crash: \"\\uebf2\"\n    readonly property string car_rental: \"\\uea55\"\n    readonly property string car_repair: \"\\uea56\"\n    readonly property string card_giftcard: \"\\ue8f6\"\n    readonly property string card_membership: \"\\ue8f7\"\n    readonly property string card_travel: \"\\ue8f8\"\n    readonly property string carpenter: \"\\uf1f8\"\n    readonly property string cases: \"\\ue992\"\n    readonly property string casino: \"\\ueb40\"\n    readonly property string cast: \"\\ue307\"\n    readonly property string cast_connected: \"\\ue308\"\n    readonly property string cast_for_education: \"\\uefec\"\n    readonly property string castle: \"\\ueab1\"\n    readonly property string catching_pokemon: \"\\ue508\"\n    readonly property string category: \"\\ue574\"\n    readonly property string celebration: \"\\uea65\"\n    readonly property string cell_tower: \"\\uebba\"\n    readonly property string cell_wifi: \"\\ue0ec\"\n    readonly property string center_focus_strong: \"\\ue3b4\"\n    readonly property string center_focus_weak: \"\\ue3b5\"\n    readonly property string chair: \"\\uefed\"\n    readonly property string chair_alt: \"\\uefee\"\n    readonly property string chalet: \"\\ue585\"\n    readonly property string change_circle: \"\\ue2e7\"\n    readonly property string change_history: \"\\ue86b\"\n    readonly property string charging_station: \"\\uf19d\"\n    readonly property string chat: \"\\ue0b7\"\n    readonly property string chat_bubble: \"\\ue0ca\"\n    readonly property string chat_bubble_outline: \"\\ue0cb\"\n    readonly property string check: \"\\ue5ca\"\n    readonly property string check_box: \"\\ue834\"\n    readonly property string check_box_outline_blank: \"\\ue835\"\n    readonly property string check_circle: \"\\ue86c\"\n    readonly property string check_circle_outline: \"\\ue92d\"\n    readonly property string checklist: \"\\ue6b1\"\n    readonly property string checklist_rtl: \"\\ue6b3\"\n    readonly property string checkroom: \"\\uf19e\"\n    readonly property string chevron_left: \"\\ue5cb\"\n    readonly property string chevron_right: \"\\ue5cc\"\n    readonly property string child_care: \"\\ueb41\"\n    readonly property string child_friendly: \"\\ueb42\"\n    readonly property string chrome_reader_mode: \"\\ue86d\"\n    readonly property string church: \"\\ueaae\"\n    readonly property string circle: \"\\uef4a\"\n    readonly property string circle_notifications: \"\\ue994\"\n    readonly property string class_: \"\\ue86e\"\n    readonly property string clean_hands: \"\\uf21f\"\n    readonly property string cleaning_services: \"\\uf0ff\"\n    readonly property string clear: \"\\ue14c\"\n    readonly property string clear_all: \"\\ue0b8\"\n    readonly property string close: \"\\ue5cd\"\n    readonly property string close_fullscreen: \"\\uf1cf\"\n    readonly property string closed_caption: \"\\ue01c\"\n    readonly property string closed_caption_disabled: \"\\uf1dc\"\n    readonly property string closed_caption_off: \"\\ue996\"\n    readonly property string cloud: \"\\ue2bd\"\n    readonly property string cloud_circle: \"\\ue2be\"\n    readonly property string cloud_done: \"\\ue2bf\"\n    readonly property string cloud_download: \"\\ue2c0\"\n    readonly property string cloud_off: \"\\ue2c1\"\n    readonly property string cloud_queue: \"\\ue2c2\"\n    readonly property string cloud_sync: \"\\ueb5a\"\n    readonly property string cloud_upload: \"\\ue2c3\"\n    readonly property string cloudy_snowing: \"\\ue810\"\n    readonly property string co2: \"\\ue7b0\"\n    readonly property string co_present: \"\\ueaf0\"\n    readonly property string code: \"\\ue86f\"\n    readonly property string code_off: \"\\ue4f3\"\n    readonly property string coffee: \"\\uefef\"\n    readonly property string coffee_maker: \"\\ueff0\"\n    readonly property string collections: \"\\ue3b6\"\n    readonly property string collections_bookmark: \"\\ue431\"\n    readonly property string color_lens: \"\\ue3b7\"\n    readonly property string colorize: \"\\ue3b8\"\n    readonly property string comment: \"\\ue0b9\"\n    readonly property string comment_bank: \"\\uea4e\"\n    readonly property string comments_disabled: \"\\ue7a2\"\n    readonly property string commit: \"\\ueaf5\"\n    readonly property string commute: \"\\ue940\"\n    readonly property string compare: \"\\ue3b9\"\n    readonly property string compare_arrows: \"\\ue915\"\n    readonly property string compass_calibration: \"\\ue57c\"\n    readonly property string compost: \"\\ue761\"\n    readonly property string compress: \"\\ue94d\"\n    readonly property string computer: \"\\ue30a\"\n    readonly property string confirmation_num: \"\\ue638\"\n    readonly property string confirmation_number: \"\\ue638\"\n    readonly property string connect_without_contact: \"\\uf223\"\n    readonly property string connected_tv: \"\\ue998\"\n    readonly property string connecting_airports: \"\\ue7c9\"\n    readonly property string construction: \"\\uea3c\"\n    readonly property string contact_emergency: \"\\uf8d1\"\n    readonly property string contact_mail: \"\\ue0d0\"\n    readonly property string contact_page: \"\\uf22e\"\n    readonly property string contact_phone: \"\\ue0cf\"\n    readonly property string contact_support: \"\\ue94c\"\n    readonly property string contactless: \"\\uea71\"\n    readonly property string contacts: \"\\ue0ba\"\n    readonly property string content_copy: \"\\ue14d\"\n    readonly property string content_cut: \"\\ue14e\"\n    readonly property string content_paste: \"\\ue14f\"\n    readonly property string content_paste_go: \"\\uea8e\"\n    readonly property string content_paste_off: \"\\ue4f8\"\n    readonly property string content_paste_search: \"\\uea9b\"\n    readonly property string contrast: \"\\ueb37\"\n    readonly property string control_camera: \"\\ue074\"\n    readonly property string control_point: \"\\ue3ba\"\n    readonly property string control_point_duplicate: \"\\ue3bb\"\n    readonly property string conveyor_belt: \"\\uf867\"\n    readonly property string cookie: \"\\ueaac\"\n    readonly property string copy_all: \"\\ue2ec\"\n    readonly property string copyright: \"\\ue90c\"\n    readonly property string coronavirus: \"\\uf221\"\n    readonly property string corporate_fare: \"\\uf1d0\"\n    readonly property string cottage: \"\\ue587\"\n    readonly property string countertops: \"\\uf1f7\"\n    readonly property string create: \"\\ue150\"\n    readonly property string create_new_folder: \"\\ue2cc\"\n    readonly property string credit_card: \"\\ue870\"\n    readonly property string credit_card_off: \"\\ue4f4\"\n    readonly property string credit_score: \"\\ueff1\"\n    readonly property string crib: \"\\ue588\"\n    readonly property string crisis_alert: \"\\uebe9\"\n    readonly property string crop: \"\\ue3be\"\n    readonly property string crop_16_9: \"\\ue3bc\"\n    readonly property string crop_3_2: \"\\ue3bd\"\n    readonly property string crop_5_4: \"\\ue3bf\"\n    readonly property string crop_7_5: \"\\ue3c0\"\n    readonly property string crop_din: \"\\ue3c1\"\n    readonly property string crop_free: \"\\ue3c2\"\n    readonly property string crop_landscape: \"\\ue3c3\"\n    readonly property string crop_original: \"\\ue3c4\"\n    readonly property string crop_portrait: \"\\ue3c5\"\n    readonly property string crop_rotate: \"\\ue437\"\n    readonly property string crop_square: \"\\ue3c6\"\n    readonly property string cruelty_free: \"\\ue799\"\n    readonly property string css: \"\\ueb93\"\n    readonly property string currency_bitcoin: \"\\uebc5\"\n    readonly property string currency_exchange: \"\\ueb70\"\n    readonly property string currency_franc: \"\\ueafa\"\n    readonly property string currency_lira: \"\\ueaef\"\n    readonly property string currency_pound: \"\\ueaf1\"\n    readonly property string currency_ruble: \"\\ueaec\"\n    readonly property string currency_rupee: \"\\ueaf7\"\n    readonly property string currency_yen: \"\\ueafb\"\n    readonly property string currency_yuan: \"\\ueaf9\"\n    readonly property string curtains: \"\\uec1e\"\n    readonly property string curtains_closed: \"\\uec1d\"\n    readonly property string cyclone: \"\\uebd5\"\n    readonly property string dangerous: \"\\ue99a\"\n    readonly property string dark_mode: \"\\ue51c\"\n    readonly property string dashboard: \"\\ue871\"\n    readonly property string dashboard_customize: \"\\ue99b\"\n    readonly property string data_array: \"\\uead1\"\n    readonly property string data_exploration: \"\\ue76f\"\n    readonly property string data_object: \"\\uead3\"\n    readonly property string data_saver_off: \"\\ueff2\"\n    readonly property string data_saver_on: \"\\ueff3\"\n    readonly property string data_thresholding: \"\\ueb9f\"\n    readonly property string data_usage: \"\\ue1af\"\n    readonly property string dataset: \"\\uf8ee\"\n    readonly property string dataset_linked: \"\\uf8ef\"\n    readonly property string date_range: \"\\ue916\"\n    readonly property string deblur: \"\\ueb77\"\n    readonly property string deck: \"\\uea42\"\n    readonly property string dehaze: \"\\ue3c7\"\n    readonly property string delete_: \"\\ue872\"\n    readonly property string delete_forever: \"\\ue92b\"\n    readonly property string delete_outline: \"\\ue92e\"\n    readonly property string delete_sweep: \"\\ue16c\"\n    readonly property string delivery_dining: \"\\uea72\"\n    readonly property string density_large: \"\\ueba9\"\n    readonly property string density_medium: \"\\ueb9e\"\n    readonly property string density_small: \"\\ueba8\"\n    readonly property string departure_board: \"\\ue576\"\n    readonly property string description: \"\\ue873\"\n    readonly property string deselect: \"\\uebb6\"\n    readonly property string design_services: \"\\uf10a\"\n    readonly property string desk: \"\\uf8f4\"\n    readonly property string desktop_access_disabled: \"\\ue99d\"\n    readonly property string desktop_mac: \"\\ue30b\"\n    readonly property string desktop_windows: \"\\ue30c\"\n    readonly property string details: \"\\ue3c8\"\n    readonly property string developer_board: \"\\ue30d\"\n    readonly property string developer_board_off: \"\\ue4ff\"\n    readonly property string developer_mode: \"\\ue1b0\"\n    readonly property string device_hub: \"\\ue335\"\n    readonly property string device_thermostat: \"\\ue1ff\"\n    readonly property string device_unknown: \"\\ue339\"\n    readonly property string devices: \"\\ue1b1\"\n    readonly property string devices_fold: \"\\uebde\"\n    readonly property string devices_other: \"\\ue337\"\n    readonly property string dew_point: \"\\uf879\"\n    readonly property string dialer_sip: \"\\ue0bb\"\n    readonly property string dialpad: \"\\ue0bc\"\n    readonly property string diamond: \"\\uead5\"\n    readonly property string difference: \"\\ueb7d\"\n    readonly property string dining: \"\\ueff4\"\n    readonly property string dinner_dining: \"\\uea57\"\n    readonly property string directions: \"\\ue52e\"\n    readonly property string directions_bike: \"\\ue52f\"\n    readonly property string directions_boat: \"\\ue532\"\n    readonly property string directions_boat_filled: \"\\ueff5\"\n    readonly property string directions_bus: \"\\ue530\"\n    readonly property string directions_bus_filled: \"\\ueff6\"\n    readonly property string directions_car: \"\\ue531\"\n    readonly property string directions_car_filled: \"\\ueff7\"\n    readonly property string directions_ferry: \"\\ue532\"\n    readonly property string directions_off: \"\\uf10f\"\n    readonly property string directions_railway: \"\\ue534\"\n    readonly property string directions_railway_filled: \"\\ueff8\"\n    readonly property string directions_run: \"\\ue566\"\n    readonly property string directions_subway: \"\\ue533\"\n    readonly property string directions_subway_filled: \"\\ueff9\"\n    readonly property string directions_train: \"\\ue534\"\n    readonly property string directions_transit: \"\\ue535\"\n    readonly property string directions_transit_filled: \"\\ueffa\"\n    readonly property string directions_walk: \"\\ue536\"\n    readonly property string dirty_lens: \"\\uef4b\"\n    readonly property string disabled_by_default: \"\\uf230\"\n    readonly property string disabled_visible: \"\\ue76e\"\n    readonly property string disc_full: \"\\ue610\"\n    readonly property string discord: \"\\uea6c\"\n    readonly property string discount: \"\\uebc9\"\n    readonly property string display_settings: \"\\ueb97\"\n    readonly property string diversity_1: \"\\uf8d7\"\n    readonly property string diversity_2: \"\\uf8d8\"\n    readonly property string diversity_3: \"\\uf8d9\"\n    readonly property string dnd_forwardslash: \"\\ue611\"\n    readonly property string dns: \"\\ue875\"\n    readonly property string do_disturb: \"\\uf08c\"\n    readonly property string do_disturb_alt: \"\\uf08d\"\n    readonly property string do_disturb_off: \"\\uf08e\"\n    readonly property string do_disturb_on: \"\\uf08f\"\n    readonly property string do_not_disturb: \"\\ue612\"\n    readonly property string do_not_disturb_alt: \"\\ue611\"\n    readonly property string do_not_disturb_off: \"\\ue643\"\n    readonly property string do_not_disturb_on: \"\\ue644\"\n    readonly property string do_not_disturb_on_total_silence: \"\\ueffb\"\n    readonly property string do_not_step: \"\\uf19f\"\n    readonly property string do_not_touch: \"\\uf1b0\"\n    readonly property string dock: \"\\ue30e\"\n    readonly property string document_scanner: \"\\ue5fa\"\n    readonly property string domain: \"\\ue7ee\"\n    readonly property string domain_add: \"\\ueb62\"\n    readonly property string domain_disabled: \"\\ue0ef\"\n    readonly property string domain_verification: \"\\uef4c\"\n    readonly property string done: \"\\ue876\"\n    readonly property string done_all: \"\\ue877\"\n    readonly property string done_outline: \"\\ue92f\"\n    readonly property string donut_large: \"\\ue917\"\n    readonly property string donut_small: \"\\ue918\"\n    readonly property string door_back: \"\\ueffc\"\n    readonly property string door_front: \"\\ueffd\"\n    readonly property string door_sliding: \"\\ueffe\"\n    readonly property string doorbell: \"\\uefff\"\n    readonly property string double_arrow: \"\\uea50\"\n    readonly property string downhill_skiing: \"\\ue509\"\n    readonly property string download: \"\\uf090\"\n    readonly property string download_done: \"\\uf091\"\n    readonly property string download_for_offline: \"\\uf000\"\n    readonly property string downloading: \"\\uf001\"\n    readonly property string drafts: \"\\ue151\"\n    readonly property string drag_handle: \"\\ue25d\"\n    readonly property string drag_indicator: \"\\ue945\"\n    readonly property string draw: \"\\ue746\"\n    readonly property string drive_eta: \"\\ue613\"\n    readonly property string drive_file_move: \"\\ue675\"\n    readonly property string drive_file_move_outline: \"\\ue9a1\"\n    readonly property string drive_file_move_rtl: \"\\ue76d\"\n    readonly property string drive_file_rename_outline: \"\\ue9a2\"\n    readonly property string drive_folder_upload: \"\\ue9a3\"\n    readonly property string dry: \"\\uf1b3\"\n    readonly property string dry_cleaning: \"\\uea58\"\n    readonly property string duo: \"\\ue9a5\"\n    readonly property string dvr: \"\\ue1b2\"\n    readonly property string dynamic_feed: \"\\uea14\"\n    readonly property string dynamic_form: \"\\uf1bf\"\n    readonly property string e_mobiledata: \"\\uf002\"\n    readonly property string earbuds: \"\\uf003\"\n    readonly property string earbuds_battery: \"\\uf004\"\n    readonly property string east: \"\\uf1df\"\n    readonly property string eco: \"\\uea35\"\n    readonly property string edgesensor_high: \"\\uf005\"\n    readonly property string edgesensor_low: \"\\uf006\"\n    readonly property string edit: \"\\ue3c9\"\n    readonly property string edit_attributes: \"\\ue578\"\n    readonly property string edit_calendar: \"\\ue742\"\n    readonly property string edit_document: \"\\uf88c\"\n    readonly property string edit_location: \"\\ue568\"\n    readonly property string edit_location_alt: \"\\ue1c5\"\n    readonly property string edit_note: \"\\ue745\"\n    readonly property string edit_notifications: \"\\ue525\"\n    readonly property string edit_off: \"\\ue950\"\n    readonly property string edit_road: \"\\uef4d\"\n    readonly property string edit_square: \"\\uf88d\"\n    readonly property string egg: \"\\ueacc\"\n    readonly property string egg_alt: \"\\ueac8\"\n    readonly property string eject: \"\\ue8fb\"\n    readonly property string elderly: \"\\uf21a\"\n    readonly property string elderly_woman: \"\\ueb69\"\n    readonly property string electric_bike: \"\\ueb1b\"\n    readonly property string electric_bolt: \"\\uec1c\"\n    readonly property string electric_car: \"\\ueb1c\"\n    readonly property string electric_meter: \"\\uec1b\"\n    readonly property string electric_moped: \"\\ueb1d\"\n    readonly property string electric_rickshaw: \"\\ueb1e\"\n    readonly property string electric_scooter: \"\\ueb1f\"\n    readonly property string electrical_services: \"\\uf102\"\n    readonly property string elevator: \"\\uf1a0\"\n    readonly property string email: \"\\ue0be\"\n    readonly property string emergency: \"\\ue1eb\"\n    readonly property string emergency_recording: \"\\uebf4\"\n    readonly property string emergency_share: \"\\uebf6\"\n    readonly property string emoji_emotions: \"\\uea22\"\n    readonly property string emoji_events: \"\\uea23\"\n    readonly property string emoji_flags: \"\\uea1a\"\n    readonly property string emoji_food_beverage: \"\\uea1b\"\n    readonly property string emoji_nature: \"\\uea1c\"\n    readonly property string emoji_objects: \"\\uea24\"\n    readonly property string emoji_people: \"\\uea1d\"\n    readonly property string emoji_symbols: \"\\uea1e\"\n    readonly property string emoji_transportation: \"\\uea1f\"\n    readonly property string energy_savings_leaf: \"\\uec1a\"\n    readonly property string engineering: \"\\uea3d\"\n    readonly property string enhance_photo_translate: \"\\ue8fc\"\n    readonly property string enhanced_encryption: \"\\ue63f\"\n    readonly property string equalizer: \"\\ue01d\"\n    readonly property string error: \"\\ue000\"\n    readonly property string error_outline: \"\\ue001\"\n    readonly property string escalator: \"\\uf1a1\"\n    readonly property string escalator_warning: \"\\uf1ac\"\n    readonly property string euro: \"\\uea15\"\n    readonly property string euro_symbol: \"\\ue926\"\n    readonly property string ev_station: \"\\ue56d\"\n    readonly property string event: \"\\ue878\"\n    readonly property string event_available: \"\\ue614\"\n    readonly property string event_busy: \"\\ue615\"\n    readonly property string event_note: \"\\ue616\"\n    readonly property string event_repeat: \"\\ueb7b\"\n    readonly property string event_seat: \"\\ue903\"\n    readonly property string exit_to_app: \"\\ue879\"\n    readonly property string expand: \"\\ue94f\"\n    readonly property string expand_circle_down: \"\\ue7cd\"\n    readonly property string expand_less: \"\\ue5ce\"\n    readonly property string expand_more: \"\\ue5cf\"\n    readonly property string explicit: \"\\ue01e\"\n    readonly property string explore: \"\\ue87a\"\n    readonly property string explore_off: \"\\ue9a8\"\n    readonly property string exposure: \"\\ue3ca\"\n    readonly property string exposure_minus_1: \"\\ue3cb\"\n    readonly property string exposure_minus_2: \"\\ue3cc\"\n    readonly property string exposure_neg_1: \"\\ue3cb\"\n    readonly property string exposure_neg_2: \"\\ue3cc\"\n    readonly property string exposure_plus_1: \"\\ue3cd\"\n    readonly property string exposure_plus_2: \"\\ue3ce\"\n    readonly property string exposure_zero: \"\\ue3cf\"\n    readonly property string extension: \"\\ue87b\"\n    readonly property string extension_off: \"\\ue4f5\"\n    readonly property string face: \"\\ue87c\"\n    readonly property string face_2: \"\\uf8da\"\n    readonly property string face_3: \"\\uf8db\"\n    readonly property string face_4: \"\\uf8dc\"\n    readonly property string face_5: \"\\uf8dd\"\n    readonly property string face_6: \"\\uf8de\"\n    readonly property string face_retouching_natural: \"\\uef4e\"\n    readonly property string face_retouching_off: \"\\uf007\"\n    readonly property string facebook: \"\\uf234\"\n    readonly property string fact_check: \"\\uf0c5\"\n    readonly property string factory: \"\\uebbc\"\n    readonly property string family_restroom: \"\\uf1a2\"\n    readonly property string fast_forward: \"\\ue01f\"\n    readonly property string fast_rewind: \"\\ue020\"\n    readonly property string fastfood: \"\\ue57a\"\n    readonly property string favorite: \"\\ue87d\"\n    readonly property string favorite_border: \"\\ue87e\"\n    readonly property string favorite_outline: \"\\ue87e\"\n    readonly property string fax: \"\\uead8\"\n    readonly property string featured_play_list: \"\\ue06d\"\n    readonly property string featured_video: \"\\ue06e\"\n    readonly property string feed: \"\\uf009\"\n    readonly property string feedback: \"\\ue87f\"\n    readonly property string female: \"\\ue590\"\n    readonly property string fence: \"\\uf1f6\"\n    readonly property string festival: \"\\uea68\"\n    readonly property string fiber_dvr: \"\\ue05d\"\n    readonly property string fiber_manual_record: \"\\ue061\"\n    readonly property string fiber_new: \"\\ue05e\"\n    readonly property string fiber_pin: \"\\ue06a\"\n    readonly property string fiber_smart_record: \"\\ue062\"\n    readonly property string file_copy: \"\\ue173\"\n    readonly property string file_download: \"\\ue2c4\"\n    readonly property string file_download_done: \"\\ue9aa\"\n    readonly property string file_download_off: \"\\ue4fe\"\n    readonly property string file_open: \"\\ueaf3\"\n    readonly property string file_present: \"\\uea0e\"\n    readonly property string file_upload: \"\\ue2c6\"\n    readonly property string file_upload_off: \"\\uf886\"\n    readonly property string filter: \"\\ue3d3\"\n    readonly property string filter_1: \"\\ue3d0\"\n    readonly property string filter_2: \"\\ue3d1\"\n    readonly property string filter_3: \"\\ue3d2\"\n    readonly property string filter_4: \"\\ue3d4\"\n    readonly property string filter_5: \"\\ue3d5\"\n    readonly property string filter_6: \"\\ue3d6\"\n    readonly property string filter_7: \"\\ue3d7\"\n    readonly property string filter_8: \"\\ue3d8\"\n    readonly property string filter_9: \"\\ue3d9\"\n    readonly property string filter_9_plus: \"\\ue3da\"\n    readonly property string filter_alt: \"\\uef4f\"\n    readonly property string filter_alt_off: \"\\ueb32\"\n    readonly property string filter_b_and_w: \"\\ue3db\"\n    readonly property string filter_center_focus: \"\\ue3dc\"\n    readonly property string filter_drama: \"\\ue3dd\"\n    readonly property string filter_frames: \"\\ue3de\"\n    readonly property string filter_hdr: \"\\ue3df\"\n    readonly property string filter_list: \"\\ue152\"\n    readonly property string filter_list_alt: \"\\ue94e\"\n    readonly property string filter_list_off: \"\\ueb57\"\n    readonly property string filter_none: \"\\ue3e0\"\n    readonly property string filter_tilt_shift: \"\\ue3e2\"\n    readonly property string filter_vintage: \"\\ue3e3\"\n    readonly property string find_in_page: \"\\ue880\"\n    readonly property string find_replace: \"\\ue881\"\n    readonly property string fingerprint: \"\\ue90d\"\n    readonly property string fire_extinguisher: \"\\uf1d8\"\n    readonly property string fire_hydrant: \"\\uf1a3\"\n    readonly property string fire_hydrant_alt: \"\\uf8f1\"\n    readonly property string fire_truck: \"\\uf8f2\"\n    readonly property string fireplace: \"\\uea43\"\n    readonly property string first_page: \"\\ue5dc\"\n    readonly property string fit_screen: \"\\uea10\"\n    readonly property string fitbit: \"\\ue82b\"\n    readonly property string fitness_center: \"\\ueb43\"\n    readonly property string flag: \"\\ue153\"\n    readonly property string flag_circle: \"\\ueaf8\"\n    readonly property string flaky: \"\\uef50\"\n    readonly property string flare: \"\\ue3e4\"\n    readonly property string flash_auto: \"\\ue3e5\"\n    readonly property string flash_off: \"\\ue3e6\"\n    readonly property string flash_on: \"\\ue3e7\"\n    readonly property string flashlight_off: \"\\uf00a\"\n    readonly property string flashlight_on: \"\\uf00b\"\n    readonly property string flatware: \"\\uf00c\"\n    readonly property string flight: \"\\ue539\"\n    readonly property string flight_class: \"\\ue7cb\"\n    readonly property string flight_land: \"\\ue904\"\n    readonly property string flight_takeoff: \"\\ue905\"\n    readonly property string flip: \"\\ue3e8\"\n    readonly property string flip_camera_android: \"\\uea37\"\n    readonly property string flip_camera_ios: \"\\uea38\"\n    readonly property string flip_to_back: \"\\ue882\"\n    readonly property string flip_to_front: \"\\ue883\"\n    readonly property string flood: \"\\uebe6\"\n    readonly property string flourescent: \"\\uec31\"\n    readonly property string flourescent2: \"\\uf00d\"\n    readonly property string fluorescent: \"\\uec31\"\n    readonly property string flutter_dash: \"\\ue00b\"\n    readonly property string fmd_bad: \"\\uf00e\"\n    readonly property string fmd_good: \"\\uf00f\"\n    readonly property string foggy: \"\\ue818\"\n    readonly property string folder: \"\\ue2c7\"\n    readonly property string folder_copy: \"\\uebbd\"\n    readonly property string folder_delete: \"\\ueb34\"\n    readonly property string folder_off: \"\\ueb83\"\n    readonly property string folder_open: \"\\ue2c8\"\n    readonly property string folder_shared: \"\\ue2c9\"\n    readonly property string folder_special: \"\\ue617\"\n    readonly property string folder_zip: \"\\ueb2c\"\n    readonly property string follow_the_signs: \"\\uf222\"\n    readonly property string font_download: \"\\ue167\"\n    readonly property string font_download_off: \"\\ue4f9\"\n    readonly property string food_bank: \"\\uf1f2\"\n    readonly property string forest: \"\\uea99\"\n    readonly property string fork_left: \"\\ueba0\"\n    readonly property string fork_right: \"\\uebac\"\n    readonly property string forklift: \"\\uf868\"\n    readonly property string format_align_center: \"\\ue234\"\n    readonly property string format_align_justify: \"\\ue235\"\n    readonly property string format_align_left: \"\\ue236\"\n    readonly property string format_align_right: \"\\ue237\"\n    readonly property string format_bold: \"\\ue238\"\n    readonly property string format_clear: \"\\ue239\"\n    readonly property string format_color_fill: \"\\ue23a\"\n    readonly property string format_color_reset: \"\\ue23b\"\n    readonly property string format_color_text: \"\\ue23c\"\n    readonly property string format_indent_decrease: \"\\ue23d\"\n    readonly property string format_indent_increase: \"\\ue23e\"\n    readonly property string format_italic: \"\\ue23f\"\n    readonly property string format_line_spacing: \"\\ue240\"\n    readonly property string format_list_bulleted: \"\\ue241\"\n    readonly property string format_list_bulleted_add: \"\\uf849\"\n    readonly property string format_list_numbered: \"\\ue242\"\n    readonly property string format_list_numbered_rtl: \"\\ue267\"\n    readonly property string format_overline: \"\\ueb65\"\n    readonly property string format_paint: \"\\ue243\"\n    readonly property string format_quote: \"\\ue244\"\n    readonly property string format_shapes: \"\\ue25e\"\n    readonly property string format_size: \"\\ue245\"\n    readonly property string format_strikethrough: \"\\ue246\"\n    readonly property string format_textdirection_l_to_r: \"\\ue247\"\n    readonly property string format_textdirection_r_to_l: \"\\ue248\"\n    readonly property string format_underline: \"\\ue249\"\n    readonly property string format_underlined: \"\\ue249\"\n    readonly property string fort: \"\\ueaad\"\n    readonly property string forum: \"\\ue0bf\"\n    readonly property string forward: \"\\ue154\"\n    readonly property string forward_10: \"\\ue056\"\n    readonly property string forward_30: \"\\ue057\"\n    readonly property string forward_5: \"\\ue058\"\n    readonly property string forward_to_inbox: \"\\uf187\"\n    readonly property string foundation: \"\\uf200\"\n    readonly property string free_breakfast: \"\\ueb44\"\n    readonly property string free_cancellation: \"\\ue748\"\n    readonly property string front_hand: \"\\ue769\"\n    readonly property string front_loader: \"\\uf869\"\n    readonly property string fullscreen: \"\\ue5d0\"\n    readonly property string fullscreen_exit: \"\\ue5d1\"\n    readonly property string functions: \"\\ue24a\"\n    readonly property string g_mobiledata: \"\\uf010\"\n    readonly property string g_translate: \"\\ue927\"\n    readonly property string gamepad: \"\\ue30f\"\n    readonly property string games: \"\\ue021\"\n    readonly property string garage: \"\\uf011\"\n    readonly property string gas_meter: \"\\uec19\"\n    readonly property string gavel: \"\\ue90e\"\n    readonly property string generating_tokens: \"\\ue749\"\n    readonly property string gesture: \"\\ue155\"\n    readonly property string get_app: \"\\ue884\"\n    readonly property string gif: \"\\ue908\"\n    readonly property string gif_box: \"\\ue7a3\"\n    readonly property string girl: \"\\ueb68\"\n    readonly property string gite: \"\\ue58b\"\n    readonly property string goat: \"\\u10fffd\"\n    readonly property string golf_course: \"\\ueb45\"\n    readonly property string gpp_bad: \"\\uf012\"\n    readonly property string gpp_good: \"\\uf013\"\n    readonly property string gpp_maybe: \"\\uf014\"\n    readonly property string gps_fixed: \"\\ue1b3\"\n    readonly property string gps_not_fixed: \"\\ue1b4\"\n    readonly property string gps_off: \"\\ue1b5\"\n    readonly property string grade: \"\\ue885\"\n    readonly property string gradient: \"\\ue3e9\"\n    readonly property string grading: \"\\uea4f\"\n    readonly property string grain: \"\\ue3ea\"\n    readonly property string graphic_eq: \"\\ue1b8\"\n    readonly property string grass: \"\\uf205\"\n    readonly property string grid_3x3: \"\\uf015\"\n    readonly property string grid_4x4: \"\\uf016\"\n    readonly property string grid_goldenratio: \"\\uf017\"\n    readonly property string grid_off: \"\\ue3eb\"\n    readonly property string grid_on: \"\\ue3ec\"\n    readonly property string grid_view: \"\\ue9b0\"\n    readonly property string group: \"\\ue7ef\"\n    readonly property string group_add: \"\\ue7f0\"\n    readonly property string group_off: \"\\ue747\"\n    readonly property string group_remove: \"\\ue7ad\"\n    readonly property string group_work: \"\\ue886\"\n    readonly property string groups: \"\\uf233\"\n    readonly property string groups_2: \"\\uf8df\"\n    readonly property string groups_3: \"\\uf8e0\"\n    readonly property string h_mobiledata: \"\\uf018\"\n    readonly property string h_plus_mobiledata: \"\\uf019\"\n    readonly property string hail: \"\\ue9b1\"\n    readonly property string handshake: \"\\uebcb\"\n    readonly property string handyman: \"\\uf10b\"\n    readonly property string hardware: \"\\uea59\"\n    readonly property string hd: \"\\ue052\"\n    readonly property string hdr_auto: \"\\uf01a\"\n    readonly property string hdr_auto_select: \"\\uf01b\"\n    readonly property string hdr_enhanced_select: \"\\uef51\"\n    readonly property string hdr_off: \"\\ue3ed\"\n    readonly property string hdr_off_select: \"\\uf01c\"\n    readonly property string hdr_on: \"\\ue3ee\"\n    readonly property string hdr_on_select: \"\\uf01d\"\n    readonly property string hdr_plus: \"\\uf01e\"\n    readonly property string hdr_strong: \"\\ue3f1\"\n    readonly property string hdr_weak: \"\\ue3f2\"\n    readonly property string headphones: \"\\uf01f\"\n    readonly property string headphones_battery: \"\\uf020\"\n    readonly property string headset: \"\\ue310\"\n    readonly property string headset_mic: \"\\ue311\"\n    readonly property string headset_off: \"\\ue33a\"\n    readonly property string healing: \"\\ue3f3\"\n    readonly property string health_and_safety: \"\\ue1d5\"\n    readonly property string hearing: \"\\ue023\"\n    readonly property string hearing_disabled: \"\\uf104\"\n    readonly property string heart_broken: \"\\ueac2\"\n    readonly property string heat_pump: \"\\uec18\"\n    readonly property string height: \"\\uea16\"\n    readonly property string help: \"\\ue887\"\n    readonly property string help_center: \"\\uf1c0\"\n    readonly property string help_outline: \"\\ue8fd\"\n    readonly property string hevc: \"\\uf021\"\n    readonly property string hexagon: \"\\ueb39\"\n    readonly property string hide_image: \"\\uf022\"\n    readonly property string hide_source: \"\\uf023\"\n    readonly property string high_quality: \"\\ue024\"\n    readonly property string highlight: \"\\ue25f\"\n    readonly property string highlight_alt: \"\\uef52\"\n    readonly property string highlight_off: \"\\ue888\"\n    readonly property string highlight_remove: \"\\ue888\"\n    readonly property string hiking: \"\\ue50a\"\n    readonly property string history: \"\\ue889\"\n    readonly property string history_edu: \"\\uea3e\"\n    readonly property string history_toggle_off: \"\\uf17d\"\n    readonly property string hive: \"\\ueaa6\"\n    readonly property string hls: \"\\ueb8a\"\n    readonly property string hls_off: \"\\ueb8c\"\n    readonly property string holiday_village: \"\\ue58a\"\n    readonly property string home: \"\\ue88a\"\n    readonly property string home_filled: \"\\ue9b2\"\n    readonly property string home_max: \"\\uf024\"\n    readonly property string home_mini: \"\\uf025\"\n    readonly property string home_repair_service: \"\\uf100\"\n    readonly property string home_work: \"\\uea09\"\n    readonly property string horizontal_distribute: \"\\ue014\"\n    readonly property string horizontal_rule: \"\\uf108\"\n    readonly property string horizontal_split: \"\\ue947\"\n    readonly property string hot_tub: \"\\ueb46\"\n    readonly property string hotel: \"\\ue53a\"\n    readonly property string hotel_class: \"\\ue743\"\n    readonly property string hourglass_bottom: \"\\uea5c\"\n    readonly property string hourglass_disabled: \"\\uef53\"\n    readonly property string hourglass_empty: \"\\ue88b\"\n    readonly property string hourglass_full: \"\\ue88c\"\n    readonly property string hourglass_top: \"\\uea5b\"\n    readonly property string house: \"\\uea44\"\n    readonly property string house_siding: \"\\uf202\"\n    readonly property string houseboat: \"\\ue584\"\n    readonly property string how_to_reg: \"\\ue174\"\n    readonly property string how_to_vote: \"\\ue175\"\n    readonly property string html: \"\\ueb7e\"\n    readonly property string http: \"\\ue902\"\n    readonly property string https: \"\\ue88d\"\n    readonly property string hub: \"\\ue9f4\"\n    readonly property string hvac: \"\\uf10e\"\n    readonly property string ice_skating: \"\\ue50b\"\n    readonly property string icecream: \"\\uea69\"\n    readonly property string image: \"\\ue3f4\"\n    readonly property string image_aspect_ratio: \"\\ue3f5\"\n    readonly property string image_not_supported: \"\\uf116\"\n    readonly property string image_search: \"\\ue43f\"\n    readonly property string imagesearch_roller: \"\\ue9b4\"\n    readonly property string import_contacts: \"\\ue0e0\"\n    readonly property string import_export: \"\\ue0c3\"\n    readonly property string important_devices: \"\\ue912\"\n    readonly property string inbox: \"\\ue156\"\n    readonly property string incomplete_circle: \"\\ue79b\"\n    readonly property string indeterminate_check_box: \"\\ue909\"\n    readonly property string info: \"\\ue88e\"\n    readonly property string info_outline: \"\\ue88f\"\n    readonly property string input: \"\\ue890\"\n    readonly property string insert_chart: \"\\ue24b\"\n    readonly property string insert_chart_outlined: \"\\ue26a\"\n    readonly property string insert_comment: \"\\ue24c\"\n    readonly property string insert_drive_file: \"\\ue24d\"\n    readonly property string insert_emoticon: \"\\ue24e\"\n    readonly property string insert_invitation: \"\\ue24f\"\n    readonly property string insert_link: \"\\ue250\"\n    readonly property string insert_page_break: \"\\ueaca\"\n    readonly property string insert_photo: \"\\ue251\"\n    readonly property string insights: \"\\uf092\"\n    readonly property string install_desktop: \"\\ueb71\"\n    readonly property string install_mobile: \"\\ueb72\"\n    readonly property string integration_instructions: \"\\uef54\"\n    readonly property string interests: \"\\ue7c8\"\n    readonly property string interpreter_mode: \"\\ue83b\"\n    readonly property string inventory: \"\\ue179\"\n    readonly property string inventory_2: \"\\ue1a1\"\n    readonly property string invert_colors: \"\\ue891\"\n    readonly property string invert_colors_off: \"\\ue0c4\"\n    readonly property string invert_colors_on: \"\\ue891\"\n    readonly property string ios_share: \"\\ue6b8\"\n    readonly property string iron: \"\\ue583\"\n    readonly property string iso: \"\\ue3f6\"\n    readonly property string javascript: \"\\ueb7c\"\n    readonly property string join_full: \"\\ueaeb\"\n    readonly property string join_inner: \"\\ueaf4\"\n    readonly property string join_left: \"\\ueaf2\"\n    readonly property string join_right: \"\\ueaea\"\n    readonly property string kayaking: \"\\ue50c\"\n    readonly property string kebab_dining: \"\\ue842\"\n    readonly property string key: \"\\ue73c\"\n    readonly property string key_off: \"\\ueb84\"\n    readonly property string keyboard: \"\\ue312\"\n    readonly property string keyboard_alt: \"\\uf028\"\n    readonly property string keyboard_arrow_down: \"\\ue313\"\n    readonly property string keyboard_arrow_left: \"\\ue314\"\n    readonly property string keyboard_arrow_right: \"\\ue315\"\n    readonly property string keyboard_arrow_up: \"\\ue316\"\n    readonly property string keyboard_backspace: \"\\ue317\"\n    readonly property string keyboard_capslock: \"\\ue318\"\n    readonly property string keyboard_command: \"\\ueae0\"\n    readonly property string keyboard_command_key: \"\\ueae7\"\n    readonly property string keyboard_control: \"\\ue5d3\"\n    readonly property string keyboard_control_key: \"\\ueae6\"\n    readonly property string keyboard_double_arrow_down: \"\\uead0\"\n    readonly property string keyboard_double_arrow_left: \"\\ueac3\"\n    readonly property string keyboard_double_arrow_right: \"\\ueac9\"\n    readonly property string keyboard_double_arrow_up: \"\\ueacf\"\n    readonly property string keyboard_hide: \"\\ue31a\"\n    readonly property string keyboard_option: \"\\ueadf\"\n    readonly property string keyboard_option_key: \"\\ueae8\"\n    readonly property string keyboard_return: \"\\ue31b\"\n    readonly property string keyboard_tab: \"\\ue31c\"\n    readonly property string keyboard_voice: \"\\ue31d\"\n    readonly property string king_bed: \"\\uea45\"\n    readonly property string kitchen: \"\\ueb47\"\n    readonly property string kitesurfing: \"\\ue50d\"\n    readonly property string label: \"\\ue892\"\n    readonly property string label_important: \"\\ue937\"\n    readonly property string label_important_outline: \"\\ue948\"\n    readonly property string label_off: \"\\ue9b6\"\n    readonly property string label_outline: \"\\ue893\"\n    readonly property string lan: \"\\ueb2f\"\n    readonly property string landscape: \"\\ue3f7\"\n    readonly property string landslide: \"\\uebd7\"\n    readonly property string language: \"\\ue894\"\n    readonly property string laptop: \"\\ue31e\"\n    readonly property string laptop_chromebook: \"\\ue31f\"\n    readonly property string laptop_mac: \"\\ue320\"\n    readonly property string laptop_windows: \"\\ue321\"\n    readonly property string last_page: \"\\ue5dd\"\n    readonly property string launch: \"\\ue895\"\n    readonly property string layers: \"\\ue53b\"\n    readonly property string layers_clear: \"\\ue53c\"\n    readonly property string leaderboard: \"\\uf20c\"\n    readonly property string leak_add: \"\\ue3f8\"\n    readonly property string leak_remove: \"\\ue3f9\"\n    readonly property string leave_bags_at_home: \"\\uf21b\"\n    readonly property string legend_toggle: \"\\uf11b\"\n    readonly property string lens: \"\\ue3fa\"\n    readonly property string lens_blur: \"\\uf029\"\n    readonly property string library_add: \"\\ue02e\"\n    readonly property string library_add_check: \"\\ue9b7\"\n    readonly property string library_books: \"\\ue02f\"\n    readonly property string library_music: \"\\ue030\"\n    readonly property string light: \"\\uf02a\"\n    readonly property string light_mode: \"\\ue518\"\n    readonly property string lightbulb: \"\\ue0f0\"\n    readonly property string lightbulb_circle: \"\\uebfe\"\n    readonly property string lightbulb_outline: \"\\ue90f\"\n    readonly property string line_axis: \"\\uea9a\"\n    readonly property string line_style: \"\\ue919\"\n    readonly property string line_weight: \"\\ue91a\"\n    readonly property string linear_scale: \"\\ue260\"\n    readonly property string link: \"\\ue157\"\n    readonly property string link_off: \"\\ue16f\"\n    readonly property string linked_camera: \"\\ue438\"\n    readonly property string liquor: \"\\uea60\"\n    readonly property string list: \"\\ue896\"\n    readonly property string list_alt: \"\\ue0ee\"\n    readonly property string live_help: \"\\ue0c6\"\n    readonly property string live_tv: \"\\ue639\"\n    readonly property string living: \"\\uf02b\"\n    readonly property string local_activity: \"\\ue53f\"\n    readonly property string local_airport: \"\\ue53d\"\n    readonly property string local_atm: \"\\ue53e\"\n    readonly property string local_attraction: \"\\ue53f\"\n    readonly property string local_bar: \"\\ue540\"\n    readonly property string local_cafe: \"\\ue541\"\n    readonly property string local_car_wash: \"\\ue542\"\n    readonly property string local_convenience_store: \"\\ue543\"\n    readonly property string local_dining: \"\\ue556\"\n    readonly property string local_drink: \"\\ue544\"\n    readonly property string local_fire_department: \"\\uef55\"\n    readonly property string local_florist: \"\\ue545\"\n    readonly property string local_gas_station: \"\\ue546\"\n    readonly property string local_grocery_store: \"\\ue547\"\n    readonly property string local_hospital: \"\\ue548\"\n    readonly property string local_hotel: \"\\ue549\"\n    readonly property string local_laundry_service: \"\\ue54a\"\n    readonly property string local_library: \"\\ue54b\"\n    readonly property string local_mall: \"\\ue54c\"\n    readonly property string local_movies: \"\\ue54d\"\n    readonly property string local_offer: \"\\ue54e\"\n    readonly property string local_parking: \"\\ue54f\"\n    readonly property string local_pharmacy: \"\\ue550\"\n    readonly property string local_phone: \"\\ue551\"\n    readonly property string local_pizza: \"\\ue552\"\n    readonly property string local_play: \"\\ue553\"\n    readonly property string local_police: \"\\uef56\"\n    readonly property string local_post_office: \"\\ue554\"\n    readonly property string local_print_shop: \"\\ue555\"\n    readonly property string local_printshop: \"\\ue555\"\n    readonly property string local_restaurant: \"\\ue556\"\n    readonly property string local_see: \"\\ue557\"\n    readonly property string local_shipping: \"\\ue558\"\n    readonly property string local_taxi: \"\\ue559\"\n    readonly property string location_city: \"\\ue7f1\"\n    readonly property string location_disabled: \"\\ue1b6\"\n    readonly property string location_history: \"\\ue55a\"\n    readonly property string location_off: \"\\ue0c7\"\n    readonly property string location_on: \"\\ue0c8\"\n    readonly property string location_pin: \"\\uf1db\"\n    readonly property string location_searching: \"\\ue1b7\"\n    readonly property string lock: \"\\ue897\"\n    readonly property string lock_clock: \"\\uef57\"\n    readonly property string lock_open: \"\\ue898\"\n    readonly property string lock_outline: \"\\ue899\"\n    readonly property string lock_person: \"\\uf8f3\"\n    readonly property string lock_reset: \"\\ueade\"\n    readonly property string login: \"\\uea77\"\n    readonly property string logo_dev: \"\\uead6\"\n    readonly property string logout: \"\\ue9ba\"\n    readonly property string looks: \"\\ue3fc\"\n    readonly property string looks_3: \"\\ue3fb\"\n    readonly property string looks_4: \"\\ue3fd\"\n    readonly property string looks_5: \"\\ue3fe\"\n    readonly property string looks_6: \"\\ue3ff\"\n    readonly property string looks_one: \"\\ue400\"\n    readonly property string looks_two: \"\\ue401\"\n    readonly property string loop: \"\\ue028\"\n    readonly property string loupe: \"\\ue402\"\n    readonly property string low_priority: \"\\ue16d\"\n    readonly property string loyalty: \"\\ue89a\"\n    readonly property string lte_mobiledata: \"\\uf02c\"\n    readonly property string lte_plus_mobiledata: \"\\uf02d\"\n    readonly property string luggage: \"\\uf235\"\n    readonly property string lunch_dining: \"\\uea61\"\n    readonly property string lyrics: \"\\uec0b\"\n    readonly property string macro_off: \"\\uf8d2\"\n    readonly property string mail: \"\\ue158\"\n    readonly property string mail_lock: \"\\uec0a\"\n    readonly property string mail_outline: \"\\ue0e1\"\n    readonly property string male: \"\\ue58e\"\n    readonly property string man: \"\\ue4eb\"\n    readonly property string man_2: \"\\uf8e1\"\n    readonly property string man_3: \"\\uf8e2\"\n    readonly property string man_4: \"\\uf8e3\"\n    readonly property string manage_accounts: \"\\uf02e\"\n    readonly property string manage_history: \"\\uebe7\"\n    readonly property string manage_search: \"\\uf02f\"\n    readonly property string map: \"\\ue55b\"\n    readonly property string maps_home_work: \"\\uf030\"\n    readonly property string maps_ugc: \"\\uef58\"\n    readonly property string margin: \"\\ue9bb\"\n    readonly property string mark_as_unread: \"\\ue9bc\"\n    readonly property string mark_chat_read: \"\\uf18b\"\n    readonly property string mark_chat_unread: \"\\uf189\"\n    readonly property string mark_email_read: \"\\uf18c\"\n    readonly property string mark_email_unread: \"\\uf18a\"\n    readonly property string mark_unread_chat_alt: \"\\ueb9d\"\n    readonly property string markunread: \"\\ue159\"\n    readonly property string markunread_mailbox: \"\\ue89b\"\n    readonly property string masks: \"\\uf218\"\n    readonly property string maximize: \"\\ue930\"\n    readonly property string media_bluetooth_off: \"\\uf031\"\n    readonly property string media_bluetooth_on: \"\\uf032\"\n    readonly property string mediation: \"\\uefa7\"\n    readonly property string medical_information: \"\\uebed\"\n    readonly property string medical_services: \"\\uf109\"\n    readonly property string medication: \"\\uf033\"\n    readonly property string medication_liquid: \"\\uea87\"\n    readonly property string meeting_room: \"\\ueb4f\"\n    readonly property string memory: \"\\ue322\"\n    readonly property string menu: \"\\ue5d2\"\n    readonly property string menu_book: \"\\uea19\"\n    readonly property string menu_open: \"\\ue9bd\"\n    readonly property string merge: \"\\ueb98\"\n    readonly property string merge_type: \"\\ue252\"\n    readonly property string message: \"\\ue0c9\"\n    readonly property string messenger: \"\\ue0ca\"\n    readonly property string messenger_outline: \"\\ue0cb\"\n    readonly property string mic: \"\\ue029\"\n    readonly property string mic_external_off: \"\\uef59\"\n    readonly property string mic_external_on: \"\\uef5a\"\n    readonly property string mic_none: \"\\ue02a\"\n    readonly property string mic_off: \"\\ue02b\"\n    readonly property string microwave: \"\\uf204\"\n    readonly property string military_tech: \"\\uea3f\"\n    readonly property string minimize: \"\\ue931\"\n    readonly property string minor_crash: \"\\uebf1\"\n    readonly property string miscellaneous_services: \"\\uf10c\"\n    readonly property string missed_video_call: \"\\ue073\"\n    readonly property string mms: \"\\ue618\"\n    readonly property string mobile_friendly: \"\\ue200\"\n    readonly property string mobile_off: \"\\ue201\"\n    readonly property string mobile_screen_share: \"\\ue0e7\"\n    readonly property string mobiledata_off: \"\\uf034\"\n    readonly property string mode: \"\\uf097\"\n    readonly property string mode_comment: \"\\ue253\"\n    readonly property string mode_edit: \"\\ue254\"\n    readonly property string mode_edit_outline: \"\\uf035\"\n    readonly property string mode_fan_off: \"\\uec17\"\n    readonly property string mode_night: \"\\uf036\"\n    readonly property string mode_of_travel: \"\\ue7ce\"\n    readonly property string mode_standby: \"\\uf037\"\n    readonly property string model_training: \"\\uf0cf\"\n    readonly property string monetization_on: \"\\ue263\"\n    readonly property string money: \"\\ue57d\"\n    readonly property string money_off: \"\\ue25c\"\n    readonly property string money_off_csred: \"\\uf038\"\n    readonly property string monitor: \"\\uef5b\"\n    readonly property string monitor_heart: \"\\ueaa2\"\n    readonly property string monitor_weight: \"\\uf039\"\n    readonly property string monochrome_photos: \"\\ue403\"\n    readonly property string mood: \"\\ue7f2\"\n    readonly property string mood_bad: \"\\ue7f3\"\n    readonly property string moped: \"\\ueb28\"\n    readonly property string more: \"\\ue619\"\n    readonly property string more_horiz: \"\\ue5d3\"\n    readonly property string more_time: \"\\uea5d\"\n    readonly property string more_vert: \"\\ue5d4\"\n    readonly property string mosque: \"\\ueab2\"\n    readonly property string motion_photos_auto: \"\\uf03a\"\n    readonly property string motion_photos_off: \"\\ue9c0\"\n    readonly property string motion_photos_on: \"\\ue9c1\"\n    readonly property string motion_photos_pause: \"\\uf227\"\n    readonly property string motion_photos_paused: \"\\ue9c2\"\n    readonly property string motorcycle: \"\\ue91b\"\n    readonly property string mouse: \"\\ue323\"\n    readonly property string move_down: \"\\ueb61\"\n    readonly property string move_to_inbox: \"\\ue168\"\n    readonly property string move_up: \"\\ueb64\"\n    readonly property string movie: \"\\ue02c\"\n    readonly property string movie_creation: \"\\ue404\"\n    readonly property string movie_edit: \"\\uf840\"\n    readonly property string movie_filter: \"\\ue43a\"\n    readonly property string moving: \"\\ue501\"\n    readonly property string mp: \"\\ue9c3\"\n    readonly property string multiline_chart: \"\\ue6df\"\n    readonly property string multiple_stop: \"\\uf1b9\"\n    readonly property string multitrack_audio: \"\\ue1b8\"\n    readonly property string museum: \"\\uea36\"\n    readonly property string music_note: \"\\ue405\"\n    readonly property string music_off: \"\\ue440\"\n    readonly property string music_video: \"\\ue063\"\n    readonly property string my_library_add: \"\\ue02e\"\n    readonly property string my_library_books: \"\\ue02f\"\n    readonly property string my_library_music: \"\\ue030\"\n    readonly property string my_location: \"\\ue55c\"\n    readonly property string nat: \"\\uef5c\"\n    readonly property string nature: \"\\ue406\"\n    readonly property string nature_people: \"\\ue407\"\n    readonly property string navigate_before: \"\\ue408\"\n    readonly property string navigate_next: \"\\ue409\"\n    readonly property string navigation: \"\\ue55d\"\n    readonly property string near_me: \"\\ue569\"\n    readonly property string near_me_disabled: \"\\uf1ef\"\n    readonly property string nearby_error: \"\\uf03b\"\n    readonly property string nearby_off: \"\\uf03c\"\n    readonly property string nest_cam_wired_stand: \"\\uec16\"\n    readonly property string network_cell: \"\\ue1b9\"\n    readonly property string network_check: \"\\ue640\"\n    readonly property string network_locked: \"\\ue61a\"\n    readonly property string network_ping: \"\\uebca\"\n    readonly property string network_wifi: \"\\ue1ba\"\n    readonly property string network_wifi_1_bar: \"\\uebe4\"\n    readonly property string network_wifi_2_bar: \"\\uebd6\"\n    readonly property string network_wifi_3_bar: \"\\uebe1\"\n    readonly property string new_label: \"\\ue609\"\n    readonly property string new_releases: \"\\ue031\"\n    readonly property string newspaper: \"\\ueb81\"\n    readonly property string next_plan: \"\\uef5d\"\n    readonly property string next_week: \"\\ue16a\"\n    readonly property string nfc: \"\\ue1bb\"\n    readonly property string night_shelter: \"\\uf1f1\"\n    readonly property string nightlife: \"\\uea62\"\n    readonly property string nightlight: \"\\uf03d\"\n    readonly property string nightlight_round: \"\\uef5e\"\n    readonly property string nights_stay: \"\\uea46\"\n    readonly property string no_accounts: \"\\uf03e\"\n    readonly property string no_adult_content: \"\\uf8fe\"\n    readonly property string no_backpack: \"\\uf237\"\n    readonly property string no_cell: \"\\uf1a4\"\n    readonly property string no_crash: \"\\uebf0\"\n    readonly property string no_drinks: \"\\uf1a5\"\n    readonly property string no_encryption: \"\\ue641\"\n    readonly property string no_encryption_gmailerrorred: \"\\uf03f\"\n    readonly property string no_flash: \"\\uf1a6\"\n    readonly property string no_food: \"\\uf1a7\"\n    readonly property string no_luggage: \"\\uf23b\"\n    readonly property string no_meals: \"\\uf1d6\"\n    readonly property string no_meals_ouline: \"\\uf229\"\n    readonly property string no_meeting_room: \"\\ueb4e\"\n    readonly property string no_photography: \"\\uf1a8\"\n    readonly property string no_sim: \"\\ue0cc\"\n    readonly property string no_stroller: \"\\uf1af\"\n    readonly property string no_transfer: \"\\uf1d5\"\n    readonly property string noise_aware: \"\\uebec\"\n    readonly property string noise_control_off: \"\\uebf3\"\n    readonly property string nordic_walking: \"\\ue50e\"\n    readonly property string north: \"\\uf1e0\"\n    readonly property string north_east: \"\\uf1e1\"\n    readonly property string north_west: \"\\uf1e2\"\n    readonly property string not_accessible: \"\\uf0fe\"\n    readonly property string not_interested: \"\\ue033\"\n    readonly property string not_listed_location: \"\\ue575\"\n    readonly property string not_started: \"\\uf0d1\"\n    readonly property string note: \"\\ue06f\"\n    readonly property string note_add: \"\\ue89c\"\n    readonly property string note_alt: \"\\uf040\"\n    readonly property string notes: \"\\ue26c\"\n    readonly property string notification_add: \"\\ue399\"\n    readonly property string notification_important: \"\\ue004\"\n    readonly property string notifications: \"\\ue7f4\"\n    readonly property string notifications_active: \"\\ue7f7\"\n    readonly property string notifications_none: \"\\ue7f5\"\n    readonly property string notifications_off: \"\\ue7f6\"\n    readonly property string notifications_on: \"\\ue7f7\"\n    readonly property string notifications_paused: \"\\ue7f8\"\n    readonly property string now_wallpaper: \"\\ue1bc\"\n    readonly property string now_widgets: \"\\ue1bd\"\n    readonly property string numbers: \"\\ueac7\"\n    readonly property string offline_bolt: \"\\ue932\"\n    readonly property string offline_pin: \"\\ue90a\"\n    readonly property string offline_share: \"\\ue9c5\"\n    readonly property string oil_barrel: \"\\uec15\"\n    readonly property string on_device_training: \"\\uebfd\"\n    readonly property string ondemand_video: \"\\ue63a\"\n    readonly property string online_prediction: \"\\uf0eb\"\n    readonly property string opacity_: \"\\ue91c\"\n    readonly property string open_in_browser: \"\\ue89d\"\n    readonly property string open_in_full: \"\\uf1ce\"\n    readonly property string open_in_new: \"\\ue89e\"\n    readonly property string open_in_new_off: \"\\ue4f6\"\n    readonly property string open_with: \"\\ue89f\"\n    readonly property string other_houses: \"\\ue58c\"\n    readonly property string outbond: \"\\uf228\"\n    readonly property string outbound: \"\\ue1ca\"\n    readonly property string outbox: \"\\uef5f\"\n    readonly property string outdoor_grill: \"\\uea47\"\n    readonly property string outgoing_mail: \"\\uf0d2\"\n    readonly property string outlet: \"\\uf1d4\"\n    readonly property string outlined_flag: \"\\ue16e\"\n    readonly property string output: \"\\uebbe\"\n    readonly property string padding: \"\\ue9c8\"\n    readonly property string pages: \"\\ue7f9\"\n    readonly property string pageview: \"\\ue8a0\"\n    readonly property string paid: \"\\uf041\"\n    readonly property string palette: \"\\ue40a\"\n    readonly property string pallet: \"\\uf86a\"\n    readonly property string pan_tool: \"\\ue925\"\n    readonly property string pan_tool_alt: \"\\uebb9\"\n    readonly property string panorama: \"\\ue40b\"\n    readonly property string panorama_fish_eye: \"\\ue40c\"\n    readonly property string panorama_fisheye: \"\\ue40c\"\n    readonly property string panorama_horizontal: \"\\ue40d\"\n    readonly property string panorama_horizontal_select: \"\\uef60\"\n    readonly property string panorama_photosphere: \"\\ue9c9\"\n    readonly property string panorama_photosphere_select: \"\\ue9ca\"\n    readonly property string panorama_vertical: \"\\ue40e\"\n    readonly property string panorama_vertical_select: \"\\uef61\"\n    readonly property string panorama_wide_angle: \"\\ue40f\"\n    readonly property string panorama_wide_angle_select: \"\\uef62\"\n    readonly property string paragliding: \"\\ue50f\"\n    readonly property string park: \"\\uea63\"\n    readonly property string party_mode: \"\\ue7fa\"\n    readonly property string pwd: \"\\uf042\"\n    readonly property string pattern: \"\\uf043\"\n    readonly property string pause: \"\\ue034\"\n    readonly property string pause_circle: \"\\ue1a2\"\n    readonly property string pause_circle_filled: \"\\ue035\"\n    readonly property string pause_circle_outline: \"\\ue036\"\n    readonly property string pause_presentation: \"\\ue0ea\"\n    readonly property string payment: \"\\ue8a1\"\n    readonly property string payments: \"\\uef63\"\n    readonly property string paypal: \"\\uea8d\"\n    readonly property string pedal_bike: \"\\ueb29\"\n    readonly property string pending: \"\\uef64\"\n    readonly property string pending_actions: \"\\uf1bb\"\n    readonly property string pentagon: \"\\ueb50\"\n    readonly property string people: \"\\ue7fb\"\n    readonly property string people_alt: \"\\uea21\"\n    readonly property string people_outline: \"\\ue7fc\"\n    readonly property string percent: \"\\ueb58\"\n    readonly property string perm_camera_mic: \"\\ue8a2\"\n    readonly property string perm_contact_cal: \"\\ue8a3\"\n    readonly property string perm_contact_calendar: \"\\ue8a3\"\n    readonly property string perm_data_setting: \"\\ue8a4\"\n    readonly property string perm_device_info: \"\\ue8a5\"\n    readonly property string perm_device_information: \"\\ue8a5\"\n    readonly property string perm_identity: \"\\ue8a6\"\n    readonly property string perm_media: \"\\ue8a7\"\n    readonly property string perm_phone_msg: \"\\ue8a8\"\n    readonly property string perm_scan_wifi: \"\\ue8a9\"\n    readonly property string person: \"\\ue7fd\"\n    readonly property string person_2: \"\\uf8e4\"\n    readonly property string person_3: \"\\uf8e5\"\n    readonly property string person_4: \"\\uf8e6\"\n    readonly property string person_add: \"\\ue7fe\"\n    readonly property string person_add_alt: \"\\uea4d\"\n    readonly property string person_add_alt_1: \"\\uef65\"\n    readonly property string person_add_disabled: \"\\ue9cb\"\n    readonly property string person_off: \"\\ue510\"\n    readonly property string person_outline: \"\\ue7ff\"\n    readonly property string person_pin: \"\\ue55a\"\n    readonly property string person_pin_circle: \"\\ue56a\"\n    readonly property string person_remove: \"\\uef66\"\n    readonly property string person_remove_alt_1: \"\\uef67\"\n    readonly property string person_search: \"\\uf106\"\n    readonly property string personal_injury: \"\\ue6da\"\n    readonly property string personal_video: \"\\ue63b\"\n    readonly property string pest_control: \"\\uf0fa\"\n    readonly property string pest_control_rodent: \"\\uf0fd\"\n    readonly property string pets: \"\\ue91d\"\n    readonly property string phishing: \"\\uead7\"\n    readonly property string phone: \"\\ue0cd\"\n    readonly property string phone_android: \"\\ue324\"\n    readonly property string phone_bluetooth_speaker: \"\\ue61b\"\n    readonly property string phone_callback: \"\\ue649\"\n    readonly property string phone_disabled: \"\\ue9cc\"\n    readonly property string phone_enabled: \"\\ue9cd\"\n    readonly property string phone_forwarded: \"\\ue61c\"\n    readonly property string phone_in_talk: \"\\ue61d\"\n    readonly property string phone_iphone: \"\\ue325\"\n    readonly property string phone_locked: \"\\ue61e\"\n    readonly property string phone_missed: \"\\ue61f\"\n    readonly property string phone_paused: \"\\ue620\"\n    readonly property string phonelink: \"\\ue326\"\n    readonly property string phonelink_erase: \"\\ue0db\"\n    readonly property string phonelink_lock: \"\\ue0dc\"\n    readonly property string phonelink_off: \"\\ue327\"\n    readonly property string phonelink_ring: \"\\ue0dd\"\n    readonly property string phonelink_setup: \"\\ue0de\"\n    readonly property string photo: \"\\ue410\"\n    readonly property string photo_album: \"\\ue411\"\n    readonly property string photo_camera: \"\\ue412\"\n    readonly property string photo_camera_back: \"\\uef68\"\n    readonly property string photo_camera_front: \"\\uef69\"\n    readonly property string photo_filter: \"\\ue43b\"\n    readonly property string photo_library: \"\\ue413\"\n    readonly property string photo_size_select_actual: \"\\ue432\"\n    readonly property string photo_size_select_large: \"\\ue433\"\n    readonly property string photo_size_select_small: \"\\ue434\"\n    readonly property string php: \"\\ueb8f\"\n    readonly property string piano: \"\\ue521\"\n    readonly property string piano_off: \"\\ue520\"\n    readonly property string picture_as_pdf: \"\\ue415\"\n    readonly property string picture_in_picture: \"\\ue8aa\"\n    readonly property string picture_in_picture_alt: \"\\ue911\"\n    readonly property string pie_chart: \"\\ue6c4\"\n    readonly property string pie_chart_outline: \"\\uf044\"\n    readonly property string pie_chart_outlined: \"\\ue6c5\"\n    readonly property string pin: \"\\uf045\"\n    readonly property string pin_drop: \"\\ue55e\"\n    readonly property string pin_end: \"\\ue767\"\n    readonly property string pin_invoke: \"\\ue763\"\n    readonly property string pinch: \"\\ueb38\"\n    readonly property string pivot_table_chart: \"\\ue9ce\"\n    readonly property string pix: \"\\ueaa3\"\n    readonly property string place: \"\\ue55f\"\n    readonly property string plagiarism: \"\\uea5a\"\n    readonly property string play_arrow: \"\\ue037\"\n    readonly property string play_circle: \"\\ue1c4\"\n    readonly property string play_circle_fill: \"\\ue038\"\n    readonly property string play_circle_filled: \"\\ue038\"\n    readonly property string play_circle_outline: \"\\ue039\"\n    readonly property string play_disabled: \"\\uef6a\"\n    readonly property string play_for_work: \"\\ue906\"\n    readonly property string play_lesson: \"\\uf047\"\n    readonly property string playlist_add: \"\\ue03b\"\n    readonly property string playlist_add_check: \"\\ue065\"\n    readonly property string playlist_add_check_circle: \"\\ue7e6\"\n    readonly property string playlist_add_circle: \"\\ue7e5\"\n    readonly property string playlist_play: \"\\ue05f\"\n    readonly property string playlist_remove: \"\\ueb80\"\n    readonly property string plumbing: \"\\uf107\"\n    readonly property string plus_one: \"\\ue800\"\n    readonly property string podcasts: \"\\uf048\"\n    readonly property string point_of_sale: \"\\uf17e\"\n    readonly property string policy: \"\\uea17\"\n    readonly property string poll: \"\\ue801\"\n    readonly property string polyline: \"\\uebbb\"\n    readonly property string polymer: \"\\ue8ab\"\n    readonly property string pool: \"\\ueb48\"\n    readonly property string portable_wifi_off: \"\\ue0ce\"\n    readonly property string portrait: \"\\ue416\"\n    readonly property string post_add: \"\\uea20\"\n    readonly property string power: \"\\ue63c\"\n    readonly property string power_input: \"\\ue336\"\n    readonly property string power_off: \"\\ue646\"\n    readonly property string power_settings_new: \"\\ue8ac\"\n    readonly property string precision_manufacturing: \"\\uf049\"\n    readonly property string pregnant_woman: \"\\ue91e\"\n    readonly property string present_to_all: \"\\ue0df\"\n    readonly property string preview: \"\\uf1c5\"\n    readonly property string price_change: \"\\uf04a\"\n    readonly property string price_check: \"\\uf04b\"\n    readonly property string print_: \"\\ue8ad\"\n    readonly property string print_disabled: \"\\ue9cf\"\n    readonly property string priority_high: \"\\ue645\"\n    readonly property string privacy_tip: \"\\uf0dc\"\n    readonly property string private_connectivity: \"\\ue744\"\n    readonly property string production_quantity_limits: \"\\ue1d1\"\n    readonly property string propane: \"\\uec14\"\n    readonly property string propane_tank: \"\\uec13\"\n    readonly property string psychology: \"\\uea4a\"\n    readonly property string psychology_alt: \"\\uf8ea\"\n    readonly property string public_: \"\\ue80b\"\n    readonly property string public_off: \"\\uf1ca\"\n    readonly property string publish: \"\\ue255\"\n    readonly property string published_with_changes: \"\\uf232\"\n    readonly property string punch_clock: \"\\ueaa8\"\n    readonly property string push_pin: \"\\uf10d\"\n    readonly property string qr_code: \"\\uef6b\"\n    readonly property string qr_code_2: \"\\ue00a\"\n    readonly property string qr_code_scanner: \"\\uf206\"\n    readonly property string query_builder: \"\\ue8ae\"\n    readonly property string query_stats: \"\\ue4fc\"\n    readonly property string question_answer: \"\\ue8af\"\n    readonly property string question_mark: \"\\ueb8b\"\n    readonly property string queue: \"\\ue03c\"\n    readonly property string queue_music: \"\\ue03d\"\n    readonly property string queue_play_next: \"\\ue066\"\n    readonly property string quick_contacts_dialer: \"\\ue0cf\"\n    readonly property string quick_contacts_mail: \"\\ue0d0\"\n    readonly property string quickreply: \"\\uef6c\"\n    readonly property string quiz: \"\\uf04c\"\n    readonly property string quora: \"\\uea98\"\n    readonly property string r_mobiledata: \"\\uf04d\"\n    readonly property string radar: \"\\uf04e\"\n    readonly property string radio: \"\\ue03e\"\n    readonly property string radio_button_checked: \"\\ue837\"\n    readonly property string radio_button_off: \"\\ue836\"\n    readonly property string radio_button_on: \"\\ue837\"\n    readonly property string radio_button_unchecked: \"\\ue836\"\n    readonly property string railway_alert: \"\\ue9d1\"\n    readonly property string ramen_dining: \"\\uea64\"\n    readonly property string ramp_left: \"\\ueb9c\"\n    readonly property string ramp_right: \"\\ueb96\"\n    readonly property string rate_review: \"\\ue560\"\n    readonly property string raw_off: \"\\uf04f\"\n    readonly property string raw_on: \"\\uf050\"\n    readonly property string read_more: \"\\uef6d\"\n    readonly property string real_estate_agent: \"\\ue73a\"\n    readonly property string rebase_edit: \"\\uf846\"\n    readonly property string receipt: \"\\ue8b0\"\n    readonly property string receipt_long: \"\\uef6e\"\n    readonly property string recent_actors: \"\\ue03f\"\n    readonly property string recommend: \"\\ue9d2\"\n    readonly property string record_voice_over: \"\\ue91f\"\n    readonly property string rectangle: \"\\ueb54\"\n    readonly property string recycling: \"\\ue760\"\n    readonly property string reddit: \"\\ueaa0\"\n    readonly property string redeem: \"\\ue8b1\"\n    readonly property string redo: \"\\ue15a\"\n    readonly property string reduce_capacity: \"\\uf21c\"\n    readonly property string refresh: \"\\ue5d5\"\n    readonly property string remember_me: \"\\uf051\"\n    readonly property string remove: \"\\ue15b\"\n    readonly property string remove_circle: \"\\ue15c\"\n    readonly property string remove_circle_outline: \"\\ue15d\"\n    readonly property string remove_done: \"\\ue9d3\"\n    readonly property string remove_from_queue: \"\\ue067\"\n    readonly property string remove_moderator: \"\\ue9d4\"\n    readonly property string remove_red_eye: \"\\ue417\"\n    readonly property string remove_road: \"\\uebfc\"\n    readonly property string remove_shopping_cart: \"\\ue928\"\n    readonly property string reorder: \"\\ue8fe\"\n    readonly property string repartition: \"\\uf8e8\"\n    readonly property string repeat: \"\\ue040\"\n    readonly property string repeat_on: \"\\ue9d6\"\n    readonly property string repeat_one: \"\\ue041\"\n    readonly property string repeat_one_on: \"\\ue9d7\"\n    readonly property string replay: \"\\ue042\"\n    readonly property string replay_10: \"\\ue059\"\n    readonly property string replay_30: \"\\ue05a\"\n    readonly property string replay_5: \"\\ue05b\"\n    readonly property string replay_circle_filled: \"\\ue9d8\"\n    readonly property string reply: \"\\ue15e\"\n    readonly property string reply_all: \"\\ue15f\"\n    readonly property string report: \"\\ue160\"\n    readonly property string report_gmailerrorred: \"\\uf052\"\n    readonly property string report_off: \"\\ue170\"\n    readonly property string report_problem: \"\\ue8b2\"\n    readonly property string request_page: \"\\uf22c\"\n    readonly property string request_quote: \"\\uf1b6\"\n    readonly property string reset_tv: \"\\ue9d9\"\n    readonly property string restart_alt: \"\\uf053\"\n    readonly property string restaurant: \"\\ue56c\"\n    readonly property string restaurant_menu: \"\\ue561\"\n    readonly property string restore: \"\\ue8b3\"\n    readonly property string restore_from_trash: \"\\ue938\"\n    readonly property string restore_page: \"\\ue929\"\n    readonly property string reviews: \"\\uf054\"\n    readonly property string rice_bowl: \"\\uf1f5\"\n    readonly property string ring_volume: \"\\ue0d1\"\n    readonly property string rocket: \"\\ueba5\"\n    readonly property string rocket_launch: \"\\ueb9b\"\n    readonly property string roller_shades: \"\\uec12\"\n    readonly property string roller_shades_closed: \"\\uec11\"\n    readonly property string roller_skating: \"\\uebcd\"\n    readonly property string roofing: \"\\uf201\"\n    readonly property string room: \"\\ue8b4\"\n    readonly property string room_preferences: \"\\uf1b8\"\n    readonly property string room_service: \"\\ueb49\"\n    readonly property string rotate_90_degrees_ccw: \"\\ue418\"\n    readonly property string rotate_90_degrees_cw: \"\\ueaab\"\n    readonly property string rotate_left: \"\\ue419\"\n    readonly property string rotate_right: \"\\ue41a\"\n    readonly property string roundabout_left: \"\\ueb99\"\n    readonly property string roundabout_right: \"\\ueba3\"\n    readonly property string rounded_corner: \"\\ue920\"\n    readonly property string route: \"\\ueacd\"\n    readonly property string router: \"\\ue328\"\n    readonly property string rowing: \"\\ue921\"\n    readonly property string rss_feed: \"\\ue0e5\"\n    readonly property string rsvp: \"\\uf055\"\n    readonly property string rtt: \"\\ue9ad\"\n    readonly property string rule: \"\\uf1c2\"\n    readonly property string rule_folder: \"\\uf1c9\"\n    readonly property string run_circle: \"\\uef6f\"\n    readonly property string running_with_errors: \"\\ue51d\"\n    readonly property string rv_hookup: \"\\ue642\"\n    readonly property string safety_check: \"\\uebef\"\n    readonly property string safety_divider: \"\\ue1cc\"\n    readonly property string sailing: \"\\ue502\"\n    readonly property string sanitizer: \"\\uf21d\"\n    readonly property string satellite: \"\\ue562\"\n    readonly property string satellite_alt: \"\\ueb3a\"\n    readonly property string save: \"\\ue161\"\n    readonly property string save_alt: \"\\ue171\"\n    readonly property string save_as: \"\\ueb60\"\n    readonly property string saved_search: \"\\uea11\"\n    readonly property string savings: \"\\ue2eb\"\n    readonly property string scale: \"\\ueb5f\"\n    readonly property string scanner: \"\\ue329\"\n    readonly property string scatter_plot: \"\\ue268\"\n    readonly property string schedule: \"\\ue8b5\"\n    readonly property string schedule_send: \"\\uea0a\"\n    readonly property string schema: \"\\ue4fd\"\n    readonly property string school: \"\\ue80c\"\n    readonly property string science: \"\\uea4b\"\n    readonly property string score: \"\\ue269\"\n    readonly property string scoreboard: \"\\uebd0\"\n    readonly property string screen_lock_landscape: \"\\ue1be\"\n    readonly property string screen_lock_portrait: \"\\ue1bf\"\n    readonly property string screen_lock_rotation: \"\\ue1c0\"\n    readonly property string screen_rotation: \"\\ue1c1\"\n    readonly property string screen_rotation_alt: \"\\uebee\"\n    readonly property string screen_search_desktop: \"\\uef70\"\n    readonly property string screen_share: \"\\ue0e2\"\n    readonly property string screenshot: \"\\uf056\"\n    readonly property string screenshot_monitor: \"\\uec08\"\n    readonly property string scuba_diving: \"\\uebce\"\n    readonly property string sd: \"\\ue9dd\"\n    readonly property string sd_card: \"\\ue623\"\n    readonly property string sd_card_alert: \"\\uf057\"\n    readonly property string sd_storage: \"\\ue1c2\"\n    readonly property string search: \"\\ue8b6\"\n    readonly property string search_off: \"\\uea76\"\n    readonly property string security: \"\\ue32a\"\n    readonly property string security_update: \"\\uf058\"\n    readonly property string security_update_good: \"\\uf059\"\n    readonly property string security_update_warning: \"\\uf05a\"\n    readonly property string segment: \"\\ue94b\"\n    readonly property string select_all: \"\\ue162\"\n    readonly property string self_improvement: \"\\uea78\"\n    readonly property string sell: \"\\uf05b\"\n    readonly property string send: \"\\ue163\"\n    readonly property string send_and_archive: \"\\uea0c\"\n    readonly property string send_time_extension: \"\\ueadb\"\n    readonly property string send_to_mobile: \"\\uf05c\"\n    readonly property string sensor_door: \"\\uf1b5\"\n    readonly property string sensor_occupied: \"\\uec10\"\n    readonly property string sensor_window: \"\\uf1b4\"\n    readonly property string sensors: \"\\ue51e\"\n    readonly property string sensors_off: \"\\ue51f\"\n    readonly property string sentiment_dissatisfied: \"\\ue811\"\n    readonly property string sentiment_neutral: \"\\ue812\"\n    readonly property string sentiment_satisfied: \"\\ue813\"\n    readonly property string sentiment_satisfied_alt: \"\\ue0ed\"\n    readonly property string sentiment_very_dissatisfied: \"\\ue814\"\n    readonly property string sentiment_very_satisfied: \"\\ue815\"\n    readonly property string set_meal: \"\\uf1ea\"\n    readonly property string settings: \"\\ue8b8\"\n    readonly property string settings_accessibility: \"\\uf05d\"\n    readonly property string settings_applications: \"\\ue8b9\"\n    readonly property string settings_backup_restore: \"\\ue8ba\"\n    readonly property string settings_bluetooth: \"\\ue8bb\"\n    readonly property string settings_brightness: \"\\ue8bd\"\n    readonly property string settings_cell: \"\\ue8bc\"\n    readonly property string settings_display: \"\\ue8bd\"\n    readonly property string settings_ethernet: \"\\ue8be\"\n    readonly property string settings_input_antenna: \"\\ue8bf\"\n    readonly property string settings_input_component: \"\\ue8c0\"\n    readonly property string settings_input_composite: \"\\ue8c1\"\n    readonly property string settings_input_hdmi: \"\\ue8c2\"\n    readonly property string settings_input_svideo: \"\\ue8c3\"\n    readonly property string settings_overscan: \"\\ue8c4\"\n    readonly property string settings_phone: \"\\ue8c5\"\n    readonly property string settings_power: \"\\ue8c6\"\n    readonly property string settings_remote: \"\\ue8c7\"\n    readonly property string settings_suggest: \"\\uf05e\"\n    readonly property string settings_system_daydream: \"\\ue1c3\"\n    readonly property string settings_voice: \"\\ue8c8\"\n    readonly property string severe_cold: \"\\uebd3\"\n    readonly property string shape_line: \"\\uf8d3\"\n    readonly property string share: \"\\ue80d\"\n    readonly property string share_arrival_time: \"\\ue524\"\n    readonly property string share_location: \"\\uf05f\"\n    readonly property string shelves: \"\\uf86e\"\n    readonly property string shield: \"\\ue9e0\"\n    readonly property string shield_moon: \"\\ueaa9\"\n    readonly property string shop: \"\\ue8c9\"\n    readonly property string shop_2: \"\\ue19e\"\n    readonly property string shop_two: \"\\ue8ca\"\n    readonly property string shopify: \"\\uea9d\"\n    readonly property string shopping_bag: \"\\uf1cc\"\n    readonly property string shopping_basket: \"\\ue8cb\"\n    readonly property string shopping_cart: \"\\ue8cc\"\n    readonly property string shopping_cart_checkout: \"\\ueb88\"\n    readonly property string short_text: \"\\ue261\"\n    readonly property string shortcut: \"\\uf060\"\n    readonly property string show_chart: \"\\ue6e1\"\n    readonly property string shower: \"\\uf061\"\n    readonly property string shuffle: \"\\ue043\"\n    readonly property string shuffle_on: \"\\ue9e1\"\n    readonly property string shutter_speed: \"\\ue43d\"\n    readonly property string sick: \"\\uf220\"\n    readonly property string sign_language: \"\\uebe5\"\n    readonly property string signal_cellular_0_bar: \"\\uf0a8\"\n    readonly property string signal_cellular_4_bar: \"\\ue1c8\"\n    readonly property string signal_cellular_alt: \"\\ue202\"\n    readonly property string signal_cellular_alt_1_bar: \"\\uebdf\"\n    readonly property string signal_cellular_alt_2_bar: \"\\uebe3\"\n    readonly property string signal_cellular_connected_no_internet_0_bar: \"\\uf0ac\"\n    readonly property string signal_cellular_connected_no_internet_4_bar: \"\\ue1cd\"\n    readonly property string signal_cellular_no_sim: \"\\ue1ce\"\n    readonly property string signal_cellular_nodata: \"\\uf062\"\n    readonly property string signal_cellular_null: \"\\ue1cf\"\n    readonly property string signal_cellular_off: \"\\ue1d0\"\n    readonly property string signal_wifi_0_bar: \"\\uf0b0\"\n    readonly property string signal_wifi_4_bar: \"\\ue1d8\"\n    readonly property string signal_wifi_4_bar_lock: \"\\ue1d9\"\n    readonly property string signal_wifi_bad: \"\\uf063\"\n    readonly property string signal_wifi_connected_no_internet_4: \"\\uf064\"\n    readonly property string signal_wifi_off: \"\\ue1da\"\n    readonly property string signal_wifi_statusbar_4_bar: \"\\uf065\"\n    readonly property string signal_wifi_statusbar_connected_no_internet_4: \"\\uf066\"\n    readonly property string signal_wifi_statusbar_null: \"\\uf067\"\n    readonly property string signpost: \"\\ueb91\"\n    readonly property string sim_card: \"\\ue32b\"\n    readonly property string sim_card_alert: \"\\ue624\"\n    readonly property string sim_card_download: \"\\uf068\"\n    readonly property string single_bed: \"\\uea48\"\n    readonly property string sip: \"\\uf069\"\n    readonly property string skateboarding: \"\\ue511\"\n    readonly property string skip_next: \"\\ue044\"\n    readonly property string skip_previous: \"\\ue045\"\n    readonly property string sledding: \"\\ue512\"\n    readonly property string slideshow: \"\\ue41b\"\n    readonly property string slow_motion_video: \"\\ue068\"\n    readonly property string smart_button: \"\\uf1c1\"\n    readonly property string smart_display: \"\\uf06a\"\n    readonly property string smart_screen: \"\\uf06b\"\n    readonly property string smart_toy: \"\\uf06c\"\n    readonly property string smartphone: \"\\ue32c\"\n    readonly property string smoke_free: \"\\ueb4a\"\n    readonly property string smoking_rooms: \"\\ueb4b\"\n    readonly property string sms: \"\\ue625\"\n    readonly property string sms_failed: \"\\ue626\"\n    readonly property string snapchat: \"\\uea6e\"\n    readonly property string snippet_folder: \"\\uf1c7\"\n    readonly property string snooze: \"\\ue046\"\n    readonly property string snowboarding: \"\\ue513\"\n    readonly property string snowing: \"\\ue80f\"\n    readonly property string snowmobile: \"\\ue503\"\n    readonly property string snowshoeing: \"\\ue514\"\n    readonly property string soap: \"\\uf1b2\"\n    readonly property string social_distance: \"\\ue1cb\"\n    readonly property string solar_power: \"\\uec0f\"\n    readonly property string sort: \"\\ue164\"\n    readonly property string sort_by_alpha: \"\\ue053\"\n    readonly property string sos: \"\\uebf7\"\n    readonly property string soup_kitchen: \"\\ue7d3\"\n    readonly property string source: \"\\uf1c4\"\n    readonly property string south: \"\\uf1e3\"\n    readonly property string south_america: \"\\ue7e4\"\n    readonly property string south_east: \"\\uf1e4\"\n    readonly property string south_west: \"\\uf1e5\"\n    readonly property string spa: \"\\ueb4c\"\n    readonly property string space_bar: \"\\ue256\"\n    readonly property string space_dashboard: \"\\ue66b\"\n    readonly property string spatial_audio: \"\\uebeb\"\n    readonly property string spatial_audio_off: \"\\uebe8\"\n    readonly property string spatial_tracking: \"\\uebea\"\n    readonly property string speaker: \"\\ue32d\"\n    readonly property string speaker_group: \"\\ue32e\"\n    readonly property string speaker_notes: \"\\ue8cd\"\n    readonly property string speaker_notes_off: \"\\ue92a\"\n    readonly property string speaker_phone: \"\\ue0d2\"\n    readonly property string speed: \"\\ue9e4\"\n    readonly property string spellcheck: \"\\ue8ce\"\n    readonly property string splitscreen: \"\\uf06d\"\n    readonly property string spoke: \"\\ue9a7\"\n    readonly property string sports: \"\\uea30\"\n    readonly property string sports_bar: \"\\uf1f3\"\n    readonly property string sports_baseball: \"\\uea51\"\n    readonly property string sports_basketball: \"\\uea26\"\n    readonly property string sports_cricket: \"\\uea27\"\n    readonly property string sports_esports: \"\\uea28\"\n    readonly property string sports_football: \"\\uea29\"\n    readonly property string sports_golf: \"\\uea2a\"\n    readonly property string sports_gymnastics: \"\\uebc4\"\n    readonly property string sports_handball: \"\\uea33\"\n    readonly property string sports_hockey: \"\\uea2b\"\n    readonly property string sports_kabaddi: \"\\uea34\"\n    readonly property string sports_martial_arts: \"\\ueae9\"\n    readonly property string sports_mma: \"\\uea2c\"\n    readonly property string sports_motorsports: \"\\uea2d\"\n    readonly property string sports_rugby: \"\\uea2e\"\n    readonly property string sports_score: \"\\uf06e\"\n    readonly property string sports_soccer: \"\\uea2f\"\n    readonly property string sports_tennis: \"\\uea32\"\n    readonly property string sports_volleyball: \"\\uea31\"\n    readonly property string square: \"\\ueb36\"\n    readonly property string square_foot: \"\\uea49\"\n    readonly property string ssid_chart: \"\\ueb66\"\n    readonly property string stacked_bar_chart: \"\\ue9e6\"\n    readonly property string stacked_line_chart: \"\\uf22b\"\n    readonly property string stadium: \"\\ueb90\"\n    readonly property string stairs: \"\\uf1a9\"\n    readonly property string star: \"\\ue838\"\n    readonly property string star_border: \"\\ue83a\"\n    readonly property string star_border_purple500: \"\\uf099\"\n    readonly property string star_half: \"\\ue839\"\n    readonly property string star_outline: \"\\uf06f\"\n    readonly property string star_purple500: \"\\uf09a\"\n    readonly property string star_rate: \"\\uf0ec\"\n    readonly property string stars: \"\\ue8d0\"\n    readonly property string start: \"\\ue089\"\n    readonly property string stay_current_landscape: \"\\ue0d3\"\n    readonly property string stay_current_portrait: \"\\ue0d4\"\n    readonly property string stay_primary_landscape: \"\\ue0d5\"\n    readonly property string stay_primary_portrait: \"\\ue0d6\"\n    readonly property string sticky_note_2: \"\\uf1fc\"\n    readonly property string stop: \"\\ue047\"\n    readonly property string stop_circle: \"\\uef71\"\n    readonly property string stop_screen_share: \"\\ue0e3\"\n    readonly property string storage: \"\\ue1db\"\n    readonly property string store: \"\\ue8d1\"\n    readonly property string store_mall_directory: \"\\ue563\"\n    readonly property string storefront: \"\\uea12\"\n    readonly property string storm: \"\\uf070\"\n    readonly property string straight: \"\\ueb95\"\n    readonly property string straighten: \"\\ue41c\"\n    readonly property string stream: \"\\ue9e9\"\n    readonly property string streetview: \"\\ue56e\"\n    readonly property string strikethrough_s: \"\\ue257\"\n    readonly property string stroller: \"\\uf1ae\"\n    readonly property string style: \"\\ue41d\"\n    readonly property string subdirectory_arrow_left: \"\\ue5d9\"\n    readonly property string subdirectory_arrow_right: \"\\ue5da\"\n    readonly property string subject: \"\\ue8d2\"\n    readonly property string subscript: \"\\uf111\"\n    readonly property string subscriptions: \"\\ue064\"\n    readonly property string subtitles: \"\\ue048\"\n    readonly property string subtitles_off: \"\\uef72\"\n    readonly property string subway: \"\\ue56f\"\n    readonly property string summarize: \"\\uf071\"\n    readonly property string sunny: \"\\ue81a\"\n    readonly property string sunny_snowing: \"\\ue819\"\n    readonly property string superscript: \"\\uf112\"\n    readonly property string supervised_user_circle: \"\\ue939\"\n    readonly property string supervisor_account: \"\\ue8d3\"\n    readonly property string support: \"\\uef73\"\n    readonly property string support_agent: \"\\uf0e2\"\n    readonly property string surfing: \"\\ue515\"\n    readonly property string surround_sound: \"\\ue049\"\n    readonly property string swap_calls: \"\\ue0d7\"\n    readonly property string swap_horiz: \"\\ue8d4\"\n    readonly property string swap_horizontal_circle: \"\\ue933\"\n    readonly property string swap_vert: \"\\ue8d5\"\n    readonly property string swap_vert_circle: \"\\ue8d6\"\n    readonly property string swap_vertical_circle: \"\\ue8d6\"\n    readonly property string swipe: \"\\ue9ec\"\n    readonly property string swipe_down: \"\\ueb53\"\n    readonly property string swipe_down_alt: \"\\ueb30\"\n    readonly property string swipe_left: \"\\ueb59\"\n    readonly property string swipe_left_alt: \"\\ueb33\"\n    readonly property string swipe_right: \"\\ueb52\"\n    readonly property string swipe_right_alt: \"\\ueb56\"\n    readonly property string swipe_up: \"\\ueb2e\"\n    readonly property string swipe_up_alt: \"\\ueb35\"\n    readonly property string swipe_vertical: \"\\ueb51\"\n    readonly property string switch_access_shortcut: \"\\ue7e1\"\n    readonly property string switch_access_shortcut_add: \"\\ue7e2\"\n    readonly property string switch_account: \"\\ue9ed\"\n    readonly property string switch_camera: \"\\ue41e\"\n    readonly property string switch_left: \"\\uf1d1\"\n    readonly property string switch_right: \"\\uf1d2\"\n    readonly property string switch_video: \"\\ue41f\"\n    readonly property string synagogue: \"\\ueab0\"\n    readonly property string sync: \"\\ue627\"\n    readonly property string sync_alt: \"\\uea18\"\n    readonly property string sync_disabled: \"\\ue628\"\n    readonly property string sync_lock: \"\\ueaee\"\n    readonly property string sync_problem: \"\\ue629\"\n    readonly property string system_security_update: \"\\uf072\"\n    readonly property string system_security_update_good: \"\\uf073\"\n    readonly property string system_security_update_warning: \"\\uf074\"\n    readonly property string system_update: \"\\ue62a\"\n    readonly property string system_update_alt: \"\\ue8d7\"\n    readonly property string system_update_tv: \"\\ue8d7\"\n    readonly property string tab: \"\\ue8d8\"\n    readonly property string tab_unselected: \"\\ue8d9\"\n    readonly property string table_bar: \"\\uead2\"\n    readonly property string table_chart: \"\\ue265\"\n    readonly property string table_restaurant: \"\\ueac6\"\n    readonly property string table_rows: \"\\uf101\"\n    readonly property string table_view: \"\\uf1be\"\n    readonly property string tablet: \"\\ue32f\"\n    readonly property string tablet_android: \"\\ue330\"\n    readonly property string tablet_mac: \"\\ue331\"\n    readonly property string tag: \"\\ue9ef\"\n    readonly property string tag_faces: \"\\ue420\"\n    readonly property string takeout_dining: \"\\uea74\"\n    readonly property string tap_and_play: \"\\ue62b\"\n    readonly property string tapas: \"\\uf1e9\"\n    readonly property string task: \"\\uf075\"\n    readonly property string task_alt: \"\\ue2e6\"\n    readonly property string taxi_alert: \"\\uef74\"\n    readonly property string telegram: \"\\uea6b\"\n    readonly property string temple_buddhist: \"\\ueab3\"\n    readonly property string temple_hindu: \"\\ueaaf\"\n    readonly property string terminal: \"\\ueb8e\"\n    readonly property string terrain: \"\\ue564\"\n    readonly property string text_decrease: \"\\ueadd\"\n    readonly property string text_fields: \"\\ue262\"\n    readonly property string text_format: \"\\ue165\"\n    readonly property string text_increase: \"\\ueae2\"\n    readonly property string text_rotate_up: \"\\ue93a\"\n    readonly property string text_rotate_vertical: \"\\ue93b\"\n    readonly property string text_rotation_angledown: \"\\ue93c\"\n    readonly property string text_rotation_angleup: \"\\ue93d\"\n    readonly property string text_rotation_down: \"\\ue93e\"\n    readonly property string text_rotation_none: \"\\ue93f\"\n    readonly property string text_snippet: \"\\uf1c6\"\n    readonly property string textsms: \"\\ue0d8\"\n    readonly property string texture: \"\\ue421\"\n    readonly property string theater_comedy: \"\\uea66\"\n    readonly property string theaters: \"\\ue8da\"\n    readonly property string thermostat: \"\\uf076\"\n    readonly property string thermostat_auto: \"\\uf077\"\n    readonly property string thumb_down: \"\\ue8db\"\n    readonly property string thumb_down_alt: \"\\ue816\"\n    readonly property string thumb_down_off_alt: \"\\ue9f2\"\n    readonly property string thumb_up: \"\\ue8dc\"\n    readonly property string thumb_up_alt: \"\\ue817\"\n    readonly property string thumb_up_off_alt: \"\\ue9f3\"\n    readonly property string thumbs_up_down: \"\\ue8dd\"\n    readonly property string thunderstorm: \"\\uebdb\"\n    readonly property string tiktok: \"\\uea7e\"\n    readonly property string time_to_leave: \"\\ue62c\"\n    readonly property string timelapse: \"\\ue422\"\n    readonly property string timeline: \"\\ue922\"\n    readonly property string timer: \"\\ue425\"\n    readonly property string timer_10: \"\\ue423\"\n    readonly property string timer_10_select: \"\\uf07a\"\n    readonly property string timer_3: \"\\ue424\"\n    readonly property string timer_3_select: \"\\uf07b\"\n    readonly property string timer_off: \"\\ue426\"\n    readonly property string tips_and_updates: \"\\ue79a\"\n    readonly property string tire_repair: \"\\uebc8\"\n    readonly property string title: \"\\ue264\"\n    readonly property string toc: \"\\ue8de\"\n    readonly property string today: \"\\ue8df\"\n    readonly property string toggle_off: \"\\ue9f5\"\n    readonly property string toggle_on: \"\\ue9f6\"\n    readonly property string token: \"\\uea25\"\n    readonly property string toll: \"\\ue8e0\"\n    readonly property string tonality: \"\\ue427\"\n    readonly property string topic: \"\\uf1c8\"\n    readonly property string tornado: \"\\ue199\"\n    readonly property string touch_app: \"\\ue913\"\n    readonly property string tour: \"\\uef75\"\n    readonly property string toys: \"\\ue332\"\n    readonly property string track_changes: \"\\ue8e1\"\n    readonly property string traffic: \"\\ue565\"\n    readonly property string train: \"\\ue570\"\n    readonly property string tram: \"\\ue571\"\n    readonly property string transcribe: \"\\uf8ec\"\n    readonly property string transfer_within_a_station: \"\\ue572\"\n    readonly property string transform_: \"\\ue428\"\n    readonly property string transgender: \"\\ue58d\"\n    readonly property string transit_enterexit: \"\\ue579\"\n    readonly property string translate: \"\\ue8e2\"\n    readonly property string travel_explore: \"\\ue2db\"\n    readonly property string trending_down: \"\\ue8e3\"\n    readonly property string trending_flat: \"\\ue8e4\"\n    readonly property string trending_neutral: \"\\ue8e4\"\n    readonly property string trending_up: \"\\ue8e5\"\n    readonly property string trip_origin: \"\\ue57b\"\n    readonly property string trolley: \"\\uf86b\"\n    readonly property string troubleshoot: \"\\ue1d2\"\n    readonly property string try_: \"\\uf07c\"\n    readonly property string tsunami: \"\\uebd8\"\n    readonly property string tty: \"\\uf1aa\"\n    readonly property string tune: \"\\ue429\"\n    readonly property string tungsten: \"\\uf07d\"\n    readonly property string turn_left: \"\\ueba6\"\n    readonly property string turn_right: \"\\uebab\"\n    readonly property string turn_sharp_left: \"\\ueba7\"\n    readonly property string turn_sharp_right: \"\\uebaa\"\n    readonly property string turn_slight_left: \"\\ueba4\"\n    readonly property string turn_slight_right: \"\\ueb9a\"\n    readonly property string turned_in: \"\\ue8e6\"\n    readonly property string turned_in_not: \"\\ue8e7\"\n    readonly property string tv: \"\\ue333\"\n    readonly property string tv_off: \"\\ue647\"\n    readonly property string two_wheeler: \"\\ue9f9\"\n    readonly property string type_specimen: \"\\uf8f0\"\n    readonly property string u_turn_left: \"\\ueba1\"\n    readonly property string u_turn_right: \"\\ueba2\"\n    readonly property string umbrella: \"\\uf1ad\"\n    readonly property string unarchive: \"\\ue169\"\n    readonly property string undo: \"\\ue166\"\n    readonly property string unfold_less: \"\\ue5d6\"\n    readonly property string unfold_less_double: \"\\uf8cf\"\n    readonly property string unfold_more: \"\\ue5d7\"\n    readonly property string unfold_more_double: \"\\uf8d0\"\n    readonly property string unpublished: \"\\uf236\"\n    readonly property string unsubscribe: \"\\ue0eb\"\n    readonly property string upcoming: \"\\uf07e\"\n    readonly property string update: \"\\ue923\"\n    readonly property string update_disabled: \"\\ue075\"\n    readonly property string upgrade: \"\\uf0fb\"\n    readonly property string upload: \"\\uf09b\"\n    readonly property string upload_file: \"\\ue9fc\"\n    readonly property string usb: \"\\ue1e0\"\n    readonly property string usb_off: \"\\ue4fa\"\n    readonly property string vaccines: \"\\ue138\"\n    readonly property string vape_free: \"\\uebc6\"\n    readonly property string vaping_rooms: \"\\uebcf\"\n    readonly property string verified: \"\\uef76\"\n    readonly property string verified_user: \"\\ue8e8\"\n    readonly property string vertical_align_bottom: \"\\ue258\"\n    readonly property string vertical_align_center: \"\\ue259\"\n    readonly property string vertical_align_top: \"\\ue25a\"\n    readonly property string vertical_distribute: \"\\ue076\"\n    readonly property string vertical_shades: \"\\uec0e\"\n    readonly property string vertical_shades_closed: \"\\uec0d\"\n    readonly property string vertical_split: \"\\ue949\"\n    readonly property string vibration: \"\\ue62d\"\n    readonly property string video_call: \"\\ue070\"\n    readonly property string video_camera_back: \"\\uf07f\"\n    readonly property string video_camera_front: \"\\uf080\"\n    readonly property string video_chat: \"\\uf8a0\"\n    readonly property string video_collection: \"\\ue04a\"\n    readonly property string video_file: \"\\ueb87\"\n    readonly property string video_label: \"\\ue071\"\n    readonly property string video_library: \"\\ue04a\"\n    readonly property string video_settings: \"\\uea75\"\n    readonly property string video_stable: \"\\uf081\"\n    readonly property string videocam: \"\\ue04b\"\n    readonly property string videocam_off: \"\\ue04c\"\n    readonly property string videogame_asset: \"\\ue338\"\n    readonly property string videogame_asset_off: \"\\ue500\"\n    readonly property string view_agenda: \"\\ue8e9\"\n    readonly property string view_array: \"\\ue8ea\"\n    readonly property string view_carousel: \"\\ue8eb\"\n    readonly property string view_column: \"\\ue8ec\"\n    readonly property string view_comfortable: \"\\ue42a\"\n    readonly property string view_comfy: \"\\ue42a\"\n    readonly property string view_comfy_alt: \"\\ueb73\"\n    readonly property string view_compact: \"\\ue42b\"\n    readonly property string view_compact_alt: \"\\ueb74\"\n    readonly property string view_cozy: \"\\ueb75\"\n    readonly property string view_day: \"\\ue8ed\"\n    readonly property string view_headline: \"\\ue8ee\"\n    readonly property string view_in_ar: \"\\ue9fe\"\n    readonly property string view_kanban: \"\\ueb7f\"\n    readonly property string view_list: \"\\ue8ef\"\n    readonly property string view_module: \"\\ue8f0\"\n    readonly property string view_quilt: \"\\ue8f1\"\n    readonly property string view_sidebar: \"\\uf114\"\n    readonly property string view_stream: \"\\ue8f2\"\n    readonly property string view_timeline: \"\\ueb85\"\n    readonly property string view_week: \"\\ue8f3\"\n    readonly property string vignette: \"\\ue435\"\n    readonly property string villa: \"\\ue586\"\n    readonly property string visibility: \"\\ue8f4\"\n    readonly property string visibility_off: \"\\ue8f5\"\n    readonly property string voice_chat: \"\\ue62e\"\n    readonly property string voice_over_off: \"\\ue94a\"\n    readonly property string voicemail: \"\\ue0d9\"\n    readonly property string volcano: \"\\uebda\"\n    readonly property string volume_down: \"\\ue04d\"\n    readonly property string volume_down_alt: \"\\ue79c\"\n    readonly property string volume_mute: \"\\ue04e\"\n    readonly property string volume_off: \"\\ue04f\"\n    readonly property string volume_up: \"\\ue050\"\n    readonly property string volunteer_activism: \"\\uea70\"\n    readonly property string vpn_key: \"\\ue0da\"\n    readonly property string vpn_key_off: \"\\ueb7a\"\n    readonly property string vpn_lock: \"\\ue62f\"\n    readonly property string vrpano: \"\\uf082\"\n    readonly property string wallet: \"\\uf8ff\"\n    readonly property string wallet_giftcard: \"\\ue8f6\"\n    readonly property string wallet_membership: \"\\ue8f7\"\n    readonly property string wallet_travel: \"\\ue8f8\"\n    readonly property string wallpaper: \"\\ue1bc\"\n    readonly property string warehouse: \"\\uebb8\"\n    readonly property string warning: \"\\ue002\"\n    readonly property string warning_amber: \"\\uf083\"\n    readonly property string wash: \"\\uf1b1\"\n    readonly property string watch: \"\\ue334\"\n    readonly property string watch_later: \"\\ue924\"\n    readonly property string watch_off: \"\\ueae3\"\n    readonly property string water: \"\\uf084\"\n    readonly property string water_damage: \"\\uf203\"\n    readonly property string water_drop: \"\\ue798\"\n    readonly property string waterfall_chart: \"\\uea00\"\n    readonly property string waves: \"\\ue176\"\n    readonly property string waving_hand: \"\\ue766\"\n    readonly property string wb_auto: \"\\ue42c\"\n    readonly property string wb_cloudy: \"\\ue42d\"\n    readonly property string wb_incandescent: \"\\ue42e\"\n    readonly property string wb_iridescent: \"\\ue436\"\n    readonly property string wb_shade: \"\\uea01\"\n    readonly property string wb_sunny: \"\\ue430\"\n    readonly property string wb_twighlight: \"\\uea02\"\n    readonly property string wb_twilight: \"\\ue1c6\"\n    readonly property string wc: \"\\ue63d\"\n    readonly property string web: \"\\ue051\"\n    readonly property string web_asset: \"\\ue069\"\n    readonly property string web_asset_off: \"\\ue4f7\"\n    readonly property string web_stories: \"\\ue595\"\n    readonly property string webhook: \"\\ueb92\"\n    readonly property string wechat: \"\\uea81\"\n    readonly property string weekend: \"\\ue16b\"\n    readonly property string west: \"\\uf1e6\"\n    readonly property string whatshot: \"\\ue80e\"\n    readonly property string wheelchair_pickup: \"\\uf1ab\"\n    readonly property string where_to_vote: \"\\ue177\"\n    readonly property string widgets: \"\\ue1bd\"\n    readonly property string width_full: \"\\uf8f5\"\n    readonly property string width_normal: \"\\uf8f6\"\n    readonly property string width_wide: \"\\uf8f7\"\n    readonly property string wifi: \"\\ue63e\"\n    readonly property string wifi_1_bar: \"\\ue4ca\"\n    readonly property string wifi_2_bar: \"\\ue4d9\"\n    readonly property string wifi_calling: \"\\uef77\"\n    readonly property string wifi_calling_3: \"\\uf085\"\n    readonly property string wifi_channel: \"\\ueb6a\"\n    readonly property string wifi_find: \"\\ueb31\"\n    readonly property string wifi_lock: \"\\ue1e1\"\n    readonly property string wifi_off: \"\\ue648\"\n    readonly property string wifi_pwd: \"\\ueb6b\"\n    readonly property string wifi_protected_setup: \"\\uf0fc\"\n    readonly property string wifi_tethering: \"\\ue1e2\"\n    readonly property string wifi_tethering_error: \"\\uead9\"\n    readonly property string wifi_tethering_error_rounded: \"\\uf086\"\n    readonly property string wifi_tethering_off: \"\\uf087\"\n    readonly property string wind_power: \"\\uec0c\"\n    readonly property string window: \"\\uf088\"\n    readonly property string wine_bar: \"\\uf1e8\"\n    readonly property string woman: \"\\ue13e\"\n    readonly property string woman_2: \"\\uf8e7\"\n    readonly property string woo_commerce: \"\\uea6d\"\n    readonly property string wordpress: \"\\uea9f\"\n    readonly property string work: \"\\ue8f9\"\n    readonly property string work_history: \"\\uec09\"\n    readonly property string work_off: \"\\ue942\"\n    readonly property string work_outline: \"\\ue943\"\n    readonly property string workspace_premium: \"\\ue7af\"\n    readonly property string workspaces: \"\\ue1a0\"\n    readonly property string workspaces_filled: \"\\uea0d\"\n    readonly property string workspaces_outline: \"\\uea0f\"\n    readonly property string wrap_text: \"\\ue25b\"\n    readonly property string wrong_location: \"\\uef78\"\n    readonly property string wysiwyg: \"\\uf1c3\"\n    readonly property string yard: \"\\uf089\"\n    readonly property string youtube_searched_for: \"\\ue8fa\"\n    readonly property string zoom_in: \"\\ue8ff\"\n    readonly property string zoom_in_map: \"\\ueb2d\"\n    readonly property string zoom_out: \"\\ue900\"\n    readonly property string zoom_out_map: \"\\ue56b\"\n}\n"
  },
  {
    "path": "meshroom/ui/qml/MaterialIcons/MaterialLabel.qml",
    "content": "import QtQuick\nimport QtQuick.Controls\n\n/**\n * MaterialLabel is a standard Label using MaterialIcons font.\n * If ToolTip.text is set, it also shows up a tooltip when hovered.\n */\n\nLabel {\n    font.family: MaterialIcons.fontFamily\n    font.pointSize: 10\n    ToolTip.visible: toolTipLoader.active && toolTipLoader.item.containsMouse\n    ToolTip.delay: 1000\n\n    Loader {\n        id: toolTipLoader\n        anchors.fill: parent\n        active: parent.ToolTip.text\n        sourceComponent: MouseArea {\n            hoverEnabled: true\n            acceptedButtons: Qt.NoButton\n        }\n    }\n}\n"
  },
  {
    "path": "meshroom/ui/qml/MaterialIcons/MaterialToolButton.qml",
    "content": "import QtQuick\nimport QtQuick.Controls\nimport QtQuick.Layouts\n\n/**\n * MaterialToolButton is a standard ToolButton using MaterialIcons font.\n * It also shows up its tooltip when hovered.\n */\n\nToolButton {\n    id: control\n    font.family: MaterialIcons.fontFamily\n    padding: 4\n    font.pointSize: 13\n    ToolTip.visible: ToolTip.text && hovered\n    ToolTip.delay: 100\n    \n    property color textColor: checked ? palette.highlight : palette.text\n    \n    Component.onCompleted:  {\n        contentItem.color = Qt.binding(function() { return textColor })\n    }\n    \n    background: Rectangle {\n        color: {\n            if (enabled && (pressed || checked || hovered)) {\n                if (pressed || checked)\n                    return Qt.darker(parent.palette.base, 1.3)\n                if (hovered)\n                    return Qt.darker(parent.palette.base, 0.6)\n            }\n            return \"transparent\"\n        }\n\n        border.color: checked ? Qt.darker(parent.palette.base, 1.4) : \"transparent\"\n    }\n}\n"
  },
  {
    "path": "meshroom/ui/qml/MaterialIcons/MaterialToolLabel.qml",
    "content": "import QtQuick\nimport QtQuick.Controls\nimport QtQuick.Layouts\n\n/**\n * MaterialToolLabel is a Label with an icon (using MaterialIcons).\n * It shows up its tooltip when hovered.\n */\n\nItem {\n    id: control\n    property alias icon: iconItem\n    property alias iconText: iconItem.text\n    property alias iconSize: iconItem.font.pointSize\n    property alias label: labelItem\n    property alias labelIconRow: contentRow\n    property alias labelIconMouseArea: mouseArea\n    property var labelIconColor: palette.text\n    implicitWidth: childrenRect.width\n    implicitHeight: childrenRect.height\n\n    RowLayout {\n        id: contentRow\n        // If we are fitting to a top container, we need to propagate the \"anchors.fill: parent\".\n        // Otherwise, the component defines its own size based on its children.\n        anchors.fill: control.anchors.fill ? parent : undefined\n        Label {\n            id: iconItem\n            font.family: MaterialIcons.fontFamily\n            font.pointSize: 13\n            padding: 0\n            text: \"\"\n            color: control.labelIconColor\n            Layout.fillWidth: false\n            Layout.fillHeight: true\n        }\n        Label {\n            id: labelItem\n            text: \"\"\n            color: control.labelIconColor\n            Layout.fillWidth: true\n            Layout.fillHeight: true\n        }\n    }\n\n    MouseArea {\n        id: mouseArea\n        anchors.fill: parent\n        hoverEnabled: true\n        acceptedButtons: Qt.NoButton\n    }\n    ToolTip.visible: mouseArea.containsMouse\n    ToolTip.delay: 500\n}\n"
  },
  {
    "path": "meshroom/ui/qml/MaterialIcons/MaterialToolLabelButton.qml",
    "content": "import QtQuick\nimport QtQuick.Controls\nimport QtQuick.Layouts\n\n/**\n * MaterialToolButton is a standard ToolButton using MaterialIcons font.\n * It also shows up its tooltip when hovered.\n */\n\nToolButton {\n    id: control\n    property alias iconText: icon.text\n    property alias iconSize: icon.font.pointSize\n    property alias label: labelItem.text\n    padding: 0\n    ToolTip.visible: ToolTip.text && hovered\n    ToolTip.delay: 100\n\n    property alias labelItem: labelItem\n    property alias iconItem: icon\n    property alias rowIconLabel: rowIconLabel\n\n    contentItem: RowLayout {\n        id: rowIconLabel\n        Layout.margins: 0\n        Label {\n            id: icon\n            font.family: MaterialIcons.fontFamily\n            font.pointSize: 13\n            padding: 0\n            text: \"\"\n            color: (checked ? palette.highlight : palette.text)\n        }\n        Label {\n            id: labelItem\n            text: \"\"\n            padding: 0\n            color: (checked ? palette.highlight : palette.text)\n            Layout.fillWidth: true\n        }\n    }\n    background: Rectangle {\n        color: {\n            if (enabled && (pressed || checked || hovered)) {\n                if (pressed || checked)\n                    return Qt.darker(parent.palette.base, 1.3)\n                if (hovered)\n                    return Qt.darker(parent.palette.base, 0.6)\n            }\n            return \"transparent\"\n        }\n\n        border.color: checked ? Qt.darker(parent.palette.base, 1.4) : \"transparent\"\n    }\n}\n"
  },
  {
    "path": "meshroom/ui/qml/MaterialIcons/generate_material_icons.py",
    "content": "import argparse\nimport os\n\nparser = argparse.ArgumentParser(description='Generate a MaterialIcons.qml singleton from codepoints file.\\n'\n                                'An example of codepoints file for MaterialIcons: https://github.com/google/material-design-icons/blob/master/font/MaterialIcons-Regular.codepoints.')\nparser.add_argument('codepoints', type=str, help='Codepoints file.')\nparser.add_argument('--output', type=str, default='.', help='')\n\nargs = parser.parse_args()\n\n# Override icons with problematic names\nmapping = {\n    'delete': 'delete_',\n    'class': 'class_',\n    '3d_rotation': '_3d_rotation',\n    'opacity': 'opacity_',\n    'transform': 'transform_',\n    'print': 'print_',\n    'public': 'public_',\n    'password': 'pwd',\n    'wifi_password': 'wifi_pwd',\n    'try': 'try_'\n}\n\n# Override icons that are numeric literals\nnumeric_literals = ['1', '2', '3', '4', '5', '6', '7', '8', '9']\n\n# List of existing name to override potential duplicates\nnames = []\n\nwith open(os.path.join(args.output, 'MaterialIcons.qml'), 'w') as qml_file:\n    qml_file.write('pragma Singleton\\n')\n    qml_file.write('import QtQuick 2.15\\n\\n')\n    qml_file.write('QtObject {\\n')\n    qml_file.write('    property FontLoader fl: FontLoader {\\n')\n    qml_file.write('        source: \"./MaterialIcons-Regular.ttf\"\\n')\n    qml_file.write('    }\\n')\n    qml_file.write('    readonly property string fontFamily: fl.name\\n\\n')\n\n    with open(args.codepoints, 'r') as codepoints:\n        for line in codepoints.readlines():\n            name, code = line.strip().split(\" \")\n            name = mapping.get(name, name)\n\n            # Add underscore to names that are numeric literals (e.g. \"123\" will become \"_123\")\n            if name[0] in numeric_literals:\n                name = \"_\" + name\n\n            # If the name already exists in the list, append an index\n            if name in names:\n                index = 2\n                while name + str(index) in names:\n                    index = index + 1\n                name = name + str(index)\n\n            names.append(name)\n            qml_file.write(f'    readonly property string {name}: \"\\\\u{code}\\\"\\n')\n        qml_file.write('}\\n')\n"
  },
  {
    "path": "meshroom/ui/qml/MaterialIcons/qmldir",
    "content": "module MaterialIcons\nsingleton MaterialIcons 2.2 MaterialIcons.qml\nMaterialToolButton 2.2 MaterialToolButton.qml\nMaterialToolLabelButton 2.2 MaterialToolLabelButton.qml\nMaterialToolLabel 2.2 MaterialToolLabel.qml\nMaterialLabel 2.2 MaterialLabel.qml\nMLabel 2.2 MLabel.qml\n"
  },
  {
    "path": "meshroom/ui/qml/Shapes/Editor/Items/ShapeAttributeItem.qml",
    "content": "import QtQuick\nimport QtQuick.Controls\n\nimport \"Utils\" as ItemUtils\n\n/**\n* ShapeAttributeItem\n*\n* @biref ShapeAttribute component for the ShapeEditor.\n* @param shapeAttribute - the given ShapeAttribute model\n* @param isNeasted - whether the item is neasted\n*/\nColumn {\n    id: shapeAttributeItem\n    width: parent.width\n    spacing: 0\n\n    // Properties\n    property var shapeAttribute\n    property alias isNeasted: itemHeader.isNeasted\n    property alias isLinkChild: itemHeader.isLinkChild\n    \n    // Item Header\n    ItemUtils.ItemHeader {\n        id: itemHeader\n        model: shapeAttribute\n        isShape: true\n        isAttribute: true\n    }\n\n    // Perhaps add an expandable list for current observations later\n}\n"
  },
  {
    "path": "meshroom/ui/qml/Shapes/Editor/Items/ShapeDataItem.qml",
    "content": "import QtQuick\nimport QtQuick.Controls\n\nimport \"Utils\" as ItemUtils\n\n/**\n* ShapeDataItem\n*\n* @biref ShapeData component for the ShapeEditor.\n* @param shapeData - the given ShapeData model\n* @param isNeasted - whether the item is neasted\n*/\nColumn {\n    id: shapeDataItem\n    width: parent.width\n    spacing: 0\n\n    // Properties\n    property var shapeData\n    property alias isNeasted: itemHeader.isNeasted\n\n    // Item Header\n    ItemUtils.ItemHeader {\n        id: itemHeader\n        model: shapeData\n        isShape: true\n        isAttribute: false\n    }\n\n    // Perhaps add an expandable list for current observations later\n}\n"
  },
  {
    "path": "meshroom/ui/qml/Shapes/Editor/Items/ShapeFileItem.qml",
    "content": "import QtQuick\nimport QtQuick.Controls\n\nimport \"Utils\" as ItemUtils\n\n/**\n* ShapeFileItem\n*\n* @biref ShapeFile component for the ShapeEditor.\n* @param shapeFile - the given ShapeFile model\n*/\nColumn {\n    id: shapeFileItem\n    width: parent.width\n    spacing: 0\n\n    // Properties\n    property var shapeFile\n\n    // Item Header\n    ItemUtils.ItemHeader {\n        id: itemHeader\n        model: shapeFile\n        isShape: false\n        isAttribute: false\n    }\n\n    // Expandable list\n    Loader {\n        active: itemHeader.isExpanded\n        width: parent.width\n        height: active ? (item ? item.implicitHeight || item.height : 0) : 0\n\n        sourceComponent: Pane {\n            background: Rectangle { color: \"transparent\" }\n            padding: 0\n            implicitWidth: parent.width\n            implicitHeight: subList.contentHeight\n\n            ListView {\n                id: subList\n                anchors.fill: parent\n                spacing: 2\n                interactive: false\n                model: shapeFile.shapes\n                delegate: ShapeDataItem {\n                    shapeData: object\n                    isNeasted: true\n                    width: ListView.view.width\n                    height: implicitHeight\n                }\n            }\n        }\n    }\n}"
  },
  {
    "path": "meshroom/ui/qml/Shapes/Editor/Items/ShapeListAttributeItem.qml",
    "content": "import QtQuick\nimport QtQuick.Controls\n\nimport \"Utils\" as ItemUtils\n\n/**\n* ShapeListAttributeItem\n*\n* @biref ShapeListAttribute component for the ShapeEditor.\n* @param shapeListAttribute - the given ShapeListAttribute model\n*/\nColumn {\n    id: shapeListAttributeItem\n    width: parent.width\n    spacing: 0\n\n    // Properties\n    property var shapeListAttribute\n\n    // Item Header\n    ItemUtils.ItemHeader {\n        id: itemHeader\n        model: shapeListAttribute\n        isShape: false\n        isAttribute: true\n    }\n\n    // Expandable list\n    Loader {\n        active: itemHeader.isExpanded\n        width: parent.width\n        height: active ? (item ? item.implicitHeight || item.height : 0) : 0\n\n        sourceComponent: Pane {\n            background: Rectangle { color: \"transparent\" }\n            padding: 0\n            implicitWidth: parent.width\n            implicitHeight: subList.contentHeight\n\n            ListView {\n                id: subList\n                anchors.fill: parent\n                spacing: 2\n                interactive: false\n                model: shapeListAttribute.value\n                delegate: ShapeAttributeItem {\n                    shapeAttribute: object\n                    isNeasted: true\n                    isLinkChild: shapeListAttribute.isLink\n                    width: ListView.view.width\n                    height: implicitHeight\n                }\n            }\n        }\n    }\n}"
  },
  {
    "path": "meshroom/ui/qml/Shapes/Editor/Items/Utils/ItemHeader.qml",
    "content": "import QtQuick\nimport QtQuick.Controls\nimport QtQuick.Layouts\nimport QtQuick.Dialogs\nimport MaterialIcons 2.2\nimport Controls 1.0\nimport Utils 1.0\n\n/**\n* ItemHeader\n*\n* @biref Item header component for the ShapeEditor.\n* @param model - the given model (provide by the current node or ShapeFilesHelper)\n* @param isShape - whether the model is a shape (ShapeAttribute or ShapeData)\n* @param isAttribute - whether the model is an attribute (ShapeAttribute or ShapeListAttribute)\n* @param isNeasted - whether the header is neasted\n* @param isLinkChild - Whether the model is a child attribute of a linked attribute\n* @param isExpanded - whether the heder is expanded\n*/\nPane {\n    id: itemHeader\n    width: parent.width\n\n    // Model properties\n    property var model\n    property bool isShape: false\n    property bool isAttribute: false\n    property bool isLinkChild: false\n\n    // Header properties\n    property bool isNeasted: false\n    property bool isExpanded: false\n\n    // Read-only properties\n    readonly property bool isAttributeSelected: isAttribute ? (ShapeViewerHelper.selectedShapeName === model.fullName) : false\n    readonly property bool isAttributeInitialized: isAttribute ? (isShape ? !model.geometry.isDefault : !model.isDefault) : false\n    readonly property bool isAttributeEnabled: isAttribute ? (model.enabled && !model.isLink && !isLinkChild) : false\n\n    // Padding\n    topPadding: 2\n    bottomPadding: 2\n    rightPadding: 6\n    leftPadding: 6\n\n    // Background\n    background: Rectangle { \n        radius: 3\n        border.color: palette.highlight\n        border.width: {\n            if(isAttributeSelected)\n                return 2\n            return 0\n        }\n        color: {\n            if(isAttributeSelected)\n                return palette.window\n            if(hoverHandler.hovered) \n                return Qt.darker(palette.window, 1.1)\n            return \"transparent\" \n        }\n\n        SequentialAnimation {\n            id: flickAnimation\n            loops: 2\n            \n            NumberAnimation {\n                target: itemHeader.background\n                property: \"border.width\"\n                to: 1\n                duration: 100\n            }\n            NumberAnimation {\n                target: itemHeader.background\n                property: \"border.width\" \n                to: 0\n                duration: 100\n            }\n            PauseAnimation { duration: 50 }\n        }\n    }\n\n    // Item header menu\n    // Popup on right mouse button \n    Menu {\n        id: itemHeaderMenu\n        MenuItem {\n            text: \"Reset\"\n            enabled: isAttributeEnabled && isAttributeInitialized\n            onTriggered: {\n                _currentScene.resetAttribute(model)\n                ShapeViewerHelper.selectedShapeName = \"\"\n                isExpanded = false\n            }\n        }\n    }\n\n    // Hover Handle\n    HoverHandler { \n        id: hoverHandler\n        margin: 3\n    }\n\n    // Tap Handler\n    // Left and Right mouse button handler\n    TapHandler {\n        acceptedButtons: Qt.LeftButton | Qt.RightButton\n        gesturePolicy: TapHandler.WithinBounds\n        margin: 3\n        onTapped: function(eventPoint, button) {\n            // Right mouse button\n            if(button === Qt.RightButton)\n                itemHeaderMenu.popup()\n\n            // Left mouse button\n            if(button === Qt.LeftButton && isShape && isAttributeEnabled && isAttributeInitialized)\n            {\n                // Single tap\n                if(tapCount === 1 && model.isVisible)\n                {\n                    ShapeViewerHelper.selectedShapeName = model.fullName\n                }\n\n                // Double tap\n                if(tapCount === 2 && !model.isVisible)\n                {\n                    \n                    ShapeViewerHelper.selectedShapeName = model.fullName\n                    model.isVisible = true\n                }\n            }\n            else\n            {\n                flickAnimation.start()\n            }\n        }\n    }\n\n    // MaterialIcons font metrics\n    FontMetrics {\n        id: materialMetrics\n        font.family: MaterialIcons.fontFamily\n        font.pointSize: 11\n    }\n\n    // Row layout\n    RowLayout {\n        anchors.fill: parent\n        anchors.rightMargin: 2\n        spacing: 0\n\n        // Shape visibility\n        MaterialToolButton {\n            font.pointSize: 9\n            padding: (materialMetrics.height / 11.0) + 2\n            text: model.isVisible ? MaterialIcons.visibility : MaterialIcons.visibility_off\n            opacity: model.isVisible ? 1.0 : 0.5\n            enabled: true\n            onClicked: { model.isVisible = !model.isVisible }\n            ToolTip.text: model.isVisible ? \"Visible\" : \"Hidden\"\n            ToolTip.visible: hovered\n            ToolTip.delay: 800\n        }\n\n        // Neasted spacer\n        // 1x icon + 2x padding\n        Item {\n            visible: isNeasted\n            width: materialMetrics.height + 4 \n        }\n\n        // Shape attributes dropdown\n        // For now, only for ShapeFile and ShapeListAttribute\n        Loader {\n            active: !isShape \n            sourceComponent: MaterialToolButton {\n                font.pointSize: 11\n                padding: 2\n                text: {\n                    if(isExpanded) {\n                        return (isShape) ?  MaterialIcons.arrow_drop_down : MaterialIcons.keyboard_arrow_down\n                    }\n                    else {\n                        return (isShape) ?  MaterialIcons.arrow_right : MaterialIcons.keyboard_arrow_right\n                    }\n                }\n                onClicked: { isExpanded = !isExpanded }\n                enabled: true\n                ToolTip.text: isExpanded ? \"Collapse\" : \"Expand\"\n                ToolTip.visible: hovered\n                ToolTip.delay: 800\n            }\n        }\n\n        // Shape color\n        Loader {\n            active: isShape \n            sourceComponent: ToolButton {\n                enabled: isAttributeEnabled\n                contentItem: Rectangle {\n                    anchors.centerIn: parent\n                    color: isAttribute ? model.userColor : model.properties.color || \"black\"\n                    width: materialMetrics.height\n                    height: materialMetrics.height\n                }\n                onClicked: shapeColorDialog.item.open()\n                ToolTip.text: \"Shape Color\"\n                ToolTip.visible: hovered\n                ToolTip.delay: 800\n            }\n        }\n\n        // Shape ColorDialog\n        Loader {\n            id: shapeColorDialog\n            active: isShape && isAttributeEnabled\n            sourceComponent: ColorDialog {\n                title: \"Edit \" + model.label + \" color\"\n                selectedColor: model.userColor\n                onAccepted: {\n                    _currentScene.setAttribute(model.childAttribute(\"userColor\"), selectedColor.toString())\n                    close()\n                }\n                onRejected: close()\n            }\n        }\n\n        // Shape type and shape name\n        RowLayout {\n            spacing: 2\n            opacity: (isAttributeEnabled && isAttributeInitialized) ? 1.0 : 0.7\n\n            // Shape type\n            MaterialLabel {\n                font.pointSize: 11\n                padding: 2\n                text: {\n                    switch(model.type) {\n                        case \"ShapeFile\": return MaterialIcons.insert_drive_file;\n                        case \"ShapeList\": return MaterialIcons.layers;\n                        case \"Point2d\":   return MaterialIcons.control_camera;\n                        case \"Line2d\":    return MaterialIcons.linear_scale;\n                        case \"Circle\":    return MaterialIcons.radio_button_unchecked;\n                        case \"Rectangle\": return MaterialIcons.crop_landscape;\n                        case \"Text\":      return MaterialIcons.title;\n                        default:          return MaterialIcons.question_mark;\n                    }\n                }\n            }\n\n            // Shape name\n            TextField {\n                font.pointSize: 8\n                background: Rectangle { color: \"transparent\" }\n                palette.text: parent.palette.text\n                maximumLength: 40 \n                selectByMouse: true\n                persistentSelection: false\n                text: {\n                    if(isAttribute && isShape && model.userName)\n                        return model.userName\n                    if(isAttribute && model.root && (model.root.type === \"ShapeList\"))\n                        return model.rootName\n                    return model.label\n                }\n                enabled: isAttributeEnabled && model.root && (model.root.type === \"ShapeList\")\n                onEditingFinished: { \n                    _currentScene.setAttribute(model.childAttribute(\"userName\"), text)\n                    focus = false\n                }\n            }\n\n            // Shape file basename\n            Loader {\n                active: !isShape && !isAttribute && model.basename !== \"\"\n                sourceComponent: Label {\n                    font.pointSize: 8\n                    text: \"(\" + model.basename + \")\"\n                }\n            }\n\n            // Shape number of observations\n            Loader {\n                active: isShape && (isAttribute ? model.geometry.observationKeyable : model.observationKeyable)\n                sourceComponent: Label {\n                    text: \"(\" + (isAttribute ? model.geometry.nbObservations : model.nbObservations) + \")\"\n                    font.pointSize: 8\n                }\n            }\n        }\n\n        // Spacer\n        Item { Layout.fillWidth: true }\n\n        // Right toolbar\n        RowLayout {\n            spacing: 0\n\n            // Static shape, set/remove observation\n            Loader {\n                active: isShape && isAttribute && !model.geometry.observationKeyable\n                sourceComponent: MaterialToolButton {\n                    font.pointSize: 11\n                    padding: 2\n                    text: isAttributeInitialized ? MaterialIcons.clear : MaterialIcons.edit\n                    checkable: false\n                    enabled: isAttributeEnabled\n                    onClicked: {\n                        if(isAttributeInitialized)\n                        {\n                            // remove key\n                            _currentScene.removeObservation(model, _currentScene.selectedViewId)\n                            ShapeViewerHelper.selectedShapeName = \"\"\n                        }\n                        else\n                        {\n                            // add key\n                            _currentScene.setObservation(model, _currentScene.selectedViewId, \n                                                          ShapeViewerHelper.getDefaultObservation(model.type))\n                            ShapeViewerHelper.selectedShapeName = model.fullName\n                        }\n                    }\n                    ToolTip.text: isAttributeInitialized ? \"Reset Shape\" : \"Set Shape\"\n                    ToolTip.visible: hovered\n                    ToolTip.delay: 800\n                }\n            }\n\n            // Shape keyable, set/remove observation\n            Loader {\n                active: isShape && (isAttribute ? model.geometry.observationKeyable : model.observationKeyable)\n                sourceComponent: RowLayout {\n                    spacing: 0\n                    property var keys: isAttribute ? model.geometry.observationKeys : model.observationKeys\n                    property bool hasCurrentKey: {\n                        if(isAttribute)\n                            return model.geometry.hasObservation(_currentScene.selectedViewId)\n                        return model.hasObservation(_currentScene.selectedViewId)\n                    }\n\n                    function getViewPath(viewId) {\n                        for (var i = 0; i < _currentScene.viewpoints.count; i++) \n                        {\n                            var vp = _currentScene.viewpoints.at(i)\n                            if (vp.childAttribute(\"viewId\").value == viewId) \n                                return vp.childAttribute(\"path\").value\n                        }\n                        return undefined\n                    }\n\n                    function getPrevViewId(viewIds, currentViewId) {\n                        const currentViewPath = getViewPath(currentViewId)\n                        const prevIds = viewIds.filter(viewId => getViewPath(viewId) < currentViewPath)\n                        if (prevIds.length === 0) \n                            return \"-1\";\n                        prevIds.sort((a, b) => getViewPath(b).localeCompare(getViewPath(a)))\n                        return prevIds[0]\n                    }\n\n                    function getNextViewId(viewIds, currentViewId) {\n                        const currentViewPath = getViewPath(currentViewId)\n                        const nextIds = viewIds.filter(viewId => getViewPath(viewId) > currentViewPath)\n                        if (nextIds.length === 0) \n                            return \"-1\";\n                        nextIds.sort((a, b) => getViewPath(a).localeCompare(getViewPath(b)))\n                        return nextIds[0]\n                    }\n\n                    // Previous key\n                    MaterialToolButton {\n                        property string prevViewId: getPrevViewId(keys, _currentScene.selectedViewId)\n                        font.pointSize: 11\n                        padding: 2\n                        text: MaterialIcons.keyboard_arrow_left\n                        checkable: false\n                        enabled: prevViewId !== \"-1\"\n                        onClicked: { _currentScene.selectedViewId = prevViewId }\n                        ToolTip.text: enabled ? \"Previous Key\" : \"No Previous Key\"\n                        ToolTip.visible: hovered\n                        ToolTip.delay: 800\n                    }\n\n                    // Current key\n                    MaterialToolButton {\n                        font.pointSize: 11\n                        padding: 2\n                        text: MaterialIcons.noise_control_off\n                        checkable: true\n                        checked: hasCurrentKey\n                        enabled: isAttributeEnabled\n                        onClicked: {\n                            if(hasCurrentKey)\n                            {\n                                // remove key\n                                _currentScene.removeObservation(model, _currentScene.selectedViewId)\n                                ShapeViewerHelper.selectedShapeName = \"\"\n                            }\n                            else\n                            {\n                                // add key\n                                _currentScene.setObservation(model, _currentScene.selectedViewId, \n                                                               ShapeViewerHelper.getDefaultObservation(model.type))\n                                ShapeViewerHelper.selectedShapeName = model.fullName\n                            }\n                        }\n                        ToolTip.text: checked ? \"Remove current key\" : \"Set current key\"\n                        ToolTip.visible: hovered\n                        ToolTip.delay: 800\n                    }\n\n                    // Next key\n                    MaterialToolButton {\n                        property string nextViewId: getNextViewId(keys, _currentScene.selectedViewId)\n                        font.pointSize: 11\n                        padding: 2\n                        text: MaterialIcons.keyboard_arrow_right\n                        checkable: false\n                        enabled: nextViewId !== \"-1\"\n                        onClicked: {  _currentScene.selectedViewId = nextViewId }\n                        ToolTip.text: enabled ? \"Next Key\" : \"No Next Key\"\n                        ToolTip.visible: hovered\n                        ToolTip.delay: 800\n                    }\n                }\n            }\n\n            // Shape list add element\n            Loader {\n                active: !isShape && isAttributeEnabled\n                sourceComponent: MaterialToolButton {\n                    font.pointSize: 11\n                    padding: 2\n                    text: MaterialIcons.control_point\n                    onClicked: _currentScene.appendAttribute(model, undefined)\n                    ToolTip.text: \"Add Element\"\n                    ToolTip.visible: hovered\n                    ToolTip.delay: 800\n                }\n            }\n\n            // Shape list delete element\n            Loader {\n                active: isAttributeEnabled && model.root && (model.root.type === \"ShapeList\")\n                sourceComponent: MaterialToolButton {\n                    font.pointSize: 11\n                    padding: 2\n                    text: MaterialIcons.remove_circle_outline\n                    onClicked: {\n                        _currentScene.removeAttribute(model)\n                    }\n                    ToolTip.text: \"Remove Element\"\n                    ToolTip.visible: hovered\n                    ToolTip.delay: 800\n                }\n            }\n\n            // Shape is a link or locked\n            Loader {\n                active: !isAttributeEnabled\n                sourceComponent: MaterialLabel {\n                    font.pointSize: 11\n                    padding: 2\n                    opacity: 0.4\n                    text: isAttribute && (model.isLink || isLinkChild) ? MaterialIcons.link : MaterialIcons.lock\n                }\n            }\n        }\n    }\n}"
  },
  {
    "path": "meshroom/ui/qml/Shapes/Editor/ShapeEditor.qml",
    "content": "import QtQuick\nimport QtQuick.Layouts\nimport QtQuick.Controls\n\n/**\n* ShapeEditor\n*\n* @biref A component to display and edit the shape attributes and shape files \n*        of the current node.\n* @param model - the given current node list of attributes\n* @param filterText - the given label filter string\n*/\nItem {\n    id: shapeEditor\n\n    // Properties\n    property alias model: attributeslist.model\n    property string filterText: \"\"\n\n    Pane {\n        anchors.fill: parent\n        anchors.margins: 2\n        padding: 5\n        background: Rectangle { color: Qt.darker(parent.palette.window, 1.4) }\n\n        ScrollView {\n            anchors.fill: parent\n\n            // Disable horizontal scrolling\n            ScrollBar.horizontal.policy: ScrollBar.AlwaysOff\n\n            // Ensure that vertical scrolling is always enabled when necessary\n            ScrollBar.vertical.policy: ScrollBar.AlwaysOn\n            ScrollBar.vertical.visible: contentHeight > height\n        \n            ColumnLayout {\n                anchors.fill: parent\n                spacing: 0\n\n                // Shape attributes\n                ListView {\n                    id: attributeslist\n                    spacing: 0\n                    interactive: false\n\n                    // Layout\n                    Layout.fillWidth: true\n                    Layout.preferredHeight: contentHeight\n\n                    delegate: ShapeEditorItem {\n                        model: object\n                        active: object.hasDisplayableShape && object.matchText(filterText)\n                        width: ListView.view.width \n                    }\n                }\n\n                // Shape files\n                ListView {\n                    spacing: 0\n                    interactive: false\n\n                    // Layout\n                    Layout.fillWidth: true\n                    Layout.preferredHeight: contentHeight\n\n                    model: ShapeFilesHelper.nodeShapeFiles\n                    delegate: ShapeEditorItem { \n                        model: object\n                        width: ListView.view.width\n                    }\n                }\n            }\n\n            // Reset selection\n            TapHandler {\n                acceptedButtons: Qt.LeftButton\n                gesturePolicy: TapHandler.WithinBounds\n                onTapped: { ShapeViewerHelper.selectedShapeName = \"\" }\n            }\n        }\n    }\n}\n"
  },
  {
    "path": "meshroom/ui/qml/Shapes/Editor/ShapeEditorItem.qml",
    "content": "import QtQuick\n\nimport \"Items\" as ShapeEditorItems\n\n/**\n* ShapeEditorItem\n*\n* @biref ShapeEditor item loader.\n* Choose the correct component for each models\n* @param model - the given ShapeAttribute / ShapeListAttribute / ShapeFile\n*/\nLoader {\n    id: itemLoader\n\n    // Properties\n    property var model: null\n\n    // Source component\n    sourceComponent: {\n        switch(itemLoader.model.type) {\n            case \"ShapeFile\": return shapeFileComponent\n            case \"ShapeList\": return shapeListAttributeComponent\n            default:          return shapeAttributeComponent\n        }\n    }\n\n    // ShapeFile component\n    Component { \n        id: shapeFileComponent\n        ShapeEditorItems.ShapeFileItem { shapeFile: itemLoader.model }\n    }\n\n    // ShapeListAttribute component\n    Component { \n        id: shapeListAttributeComponent\n        ShapeEditorItems.ShapeListAttributeItem { shapeListAttribute: itemLoader.model }\n    }\n\n    // ShapeAttribute component\n    Component { \n        id: shapeAttributeComponent; \n        ShapeEditorItems.ShapeAttributeItem { shapeAttribute: itemLoader.model } \n    }\n}\n"
  },
  {
    "path": "meshroom/ui/qml/Shapes/Viewer/Layers/BaseLayer.qml",
    "content": "import QtQuick\n\n/**\n* BaseLayer\n*\n* @biref Shape layer base component for displaying and modifying shapes.\n* @param name - the given shape name\n* @param properties - the given shape style properties\n* @param observation - the given shape position and dimensions for the current view\n* @param editable - the shape is editable\n* @param scaleRatio - the shape container scale ratio (scroll zoom)\n* @param selected - the shape is selected\n*/\nItem {\n    id: baseLayer\n\n    // Shape layer fills the parent\n    anchors.fill: parent\n\n    // Shape name\n    property string name: \"unknown\"\n\n    // Shape properties\n    property var properties: ({})\n\n    // Shape observation\n    property var observation: ({})\n\n    // Shape is editable\n    property bool editable: false\n\n    // Shape container scale ratio\n    property real scaleRatio: 1.0\n\n    // Shape is selected\n    property bool selected: ShapeViewerHelper.selectedShapeName === name\n\n    // Shape default color \n    readonly property color defaultColor: \"#3366cc\"\n\n    // Request selection\n    function selectionRequested() {\n        ShapeViewerHelper.selectedShapeName = name\n    }\n\n    // Helper function to get scaled handle size\n    function getScaledHandleSize() {\n        return Math.max(0.5, 8.0 * scaleRatio)\n    }\n\n    // Helper function to get scaled stroke width\n    function getScaledStrokeWidth() {\n        return Math.max(0.05, (baseLayer.properties.strokeWidth || 2.0) * baseLayer.scaleRatio)\n    }\n\n    // Helper function to get scaled helper stroke width\n    function getScaledHelperStrokeWidth() {\n        return Math.max(0.05, baseLayer.scaleRatio)\n    }\n\n    // Helper function to get scaled font size\n    function getScaledFontSize() {\n        return Math.max(1.0, (baseLayer.properties.fontSize || 10.0) * baseLayer.scaleRatio)\n    }\n}"
  },
  {
    "path": "meshroom/ui/qml/Shapes/Viewer/Layers/CircleLayer.qml",
    "content": "import QtQuick\nimport QtQuick.Shapes\n\nimport \"Utils\" as LayerUtils\n\n/**\n* CircleLayer\n*\n* @biref Allows to display and modify a circle.\n* @param name - the given shape name\n* @param properties - the given shape style properties\n* @param observation - the given shape position and dimensions for the current view\n* @param editable - the shape is editable\n* @param scaleRatio - the shape container scale ratio (scroll zoom)\n* @param selected - the shape is selected\n* @see BaseLayer.qml\n*/\nBaseLayer {\n    id: circleLayer\n\n    // Circle radius from handleRadius position\n    property real circleRadius: Math.max(1.0, Math.sqrt(Math.pow(handleRadius.x - handleCenter.x, 2) +\n                                                        Math.pow(handleRadius.y - handleCenter.y, 2)))\n                                     \n    // Circle shape\n    Shape {\n        id: draggableShape\n\n        // Circle path\n        ShapePath {\n            fillColor: circleLayer.properties.fillColor || \"transparent\"\n            strokeColor: circleLayer.properties.strokeColor || circleLayer.properties.color || circleLayer.defaultColor\n            strokeWidth: getScaledStrokeWidth()\n\n            // Circle\n            PathRectangle {\n                x: circleLayer.observation.center.x - circleRadius\n                y: circleLayer.observation.center.y - circleRadius\n                width: circleRadius * 2\n                height: circleRadius * 2\n                radius: circleRadius\n            }\n\n            // Center cross\n            PathMove { x: circleLayer.observation.center.x - 10; y: circleLayer.observation.center.y }\n            PathLine { x: circleLayer.observation.center.x + 10; y: circleLayer.observation.center.y }\n            PathMove { x: circleLayer.observation.center.x; y: circleLayer.observation.center.y - 10 }\n            PathLine { x: circleLayer.observation.center.x; y: circleLayer.observation.center.y + 10 }\n        }\n\n        // Radius helper path\n        ShapePath {\n            fillColor: \"transparent\"\n            strokeColor: circleLayer.selected ? \"#bbffffff\" : \"transparent\"\n            strokeWidth: getScaledHelperStrokeWidth()\n\n            PathMove { x: circleLayer.observation.center.x; y: circleLayer.observation.center.y }\n            PathLine { x: handleRadius.x; y: handleRadius.y }\n        }\n\n        // Selection area\n        MouseArea  {\n            x: handleCenter.x - circleRadius\n            y: handleCenter.y - circleRadius\n            width: circleRadius * 2\n            height: circleRadius * 2\n            acceptedButtons: Qt.LeftButton\n            cursorShape: circleLayer.editable ? Qt.PointingHandCursor : Qt.ArrowCursor\n            onClicked: selectionRequested()\n            enabled: circleLayer.editable && !circleLayer.selected\n        }\n        \n        // Handle for circle center\n        LayerUtils.Handle {\n            id: handleCenter\n            x: circleLayer.observation.center.x || 0\n            y: circleLayer.observation.center.y || 0\n            size: getScaledHandleSize()\n            target: draggableShape\n            cursorShape: Qt.SizeAllCursor\n            visible: circleLayer.editable && circleLayer.selected\n            onMoved: {\n                _currentScene.setObservationFromName(circleLayer.name, _currentScene.selectedViewId, { \n                    center: {\n                        x: handleCenter.x + draggableShape.x, \n                        y: handleCenter.y + draggableShape.y \n                    } \n                })\n            }\n        }\n\n        // Handle for circle radius\n        LayerUtils.Handle {\n            id: handleRadius\n            x: circleLayer.observation.center.x + circleLayer.observation.radius || 0\n            y: circleLayer.observation.center.y || 0\n            size: getScaledHandleSize()\n            cursorShape: Qt.SizeBDiagCursor\n            visible: circleLayer.editable && circleLayer.selected\n            onMoved: {\n                _currentScene.setObservationFromName(circleLayer.name, _currentScene.selectedViewId, { \n                    radius: circleRadius \n                })\n            }\n        }\n    }\n}\n\n"
  },
  {
    "path": "meshroom/ui/qml/Shapes/Viewer/Layers/LineLayer.qml",
    "content": "import QtQuick\nimport QtQuick.Shapes\n\nimport \"Utils\" as LayerUtils\n\n/**\n* LineLayer\n*\n* @biref Allows to display and modify a line.\n* @param name - the given shape name\n* @param properties - the given shape style properties\n* @param observation - the given shape position and dimensions for the current view\n* @param editable - the shape is editable\n* @param scaleRatio - the shape container scale ratio (scroll zoom)\n* @param selected - the shape is selected\n* @see BaseLayer.qml\n*/\nBaseLayer {\n    id: lineLayer\n\n    // Line center from handleA and handleB position\n    property point lineCenter: Qt.point((handleA.x + handleB.x) * 0.5, (handleA.y + handleB.y) * 0.5)\n    // Line angle from handleA and handleB position\n    property real lineAngle: Math.atan2(handleB.y - handleA.y, handleB.x - handleA.x)\n    // Line distance from handleA and handleB position\n    property real lineDistance: Math.max(1.0, Math.sqrt(Math.pow(handleA.x - handleB.x, 2) +\n                                                        Math.pow(handleA.y - handleB.y, 2)))\n\n    // Line shape\n    Shape {\n        id: draggableLine\n\n        // Line path\n        ShapePath {\n            fillColor: \"transparent\"\n            strokeColor: lineLayer.properties.strokeColor || lineLayer.properties.color || lineLayer.defaultColor\n            strokeWidth: getScaledStrokeWidth()\n\n            // Line\n            PathMove { x: handleA.x; y: handleA.y }\n            PathLine { x: handleB.x; y: handleB.y }\n\n            // Orientation center arrow\n            PathMove {\n                x: lineCenter.x - lineDistance * 0.1 * Math.cos(lineAngle - Math.PI * 0.25)\n                y: lineCenter.y - lineDistance * 0.1 * Math.sin(lineAngle - Math.PI * 0.25)\n            }\n            PathLine { x: lineCenter.x; y: lineCenter.y }\n            PathLine { \n                x: lineCenter.x - lineDistance * 0.1 * Math.cos(lineAngle + Math.PI * 0.25)\n                y: lineCenter.y - lineDistance * 0.1 * Math.sin(lineAngle + Math.PI * 0.25)\n            }\n        }\n\n        // Selection area\n        MouseArea  {\n            x: Math.min(handleA.x, handleB.x)\n            y: Math.min(handleA.y, handleB.y)\n            width: Math.abs(handleA.x - handleB.x) \n            height: Math.abs(handleA.y - handleB.y)\n            acceptedButtons: Qt.LeftButton\n            cursorShape: lineLayer.editable ? Qt.PointingHandCursor : Qt.ArrowCursor\n            onClicked: selectionRequested()\n            enabled: lineLayer.editable && !lineLayer.selected\n        }\n\n        // Handle for point A\n        LayerUtils.Handle {\n            id: handleA\n            x: lineLayer.observation.a.x || 0\n            y: lineLayer.observation.a.y || 0\n            size: getScaledHandleSize()\n            cursorShape: Qt.SizeAllCursor\n            visible: lineLayer.editable && lineLayer.selected\n            onMoved: {\n                _currentScene.setObservationFromName(lineLayer.name, _currentScene.selectedViewId, {\n                    a: {\n                        x: handleA.x + draggableLine.x,\n                        y: handleA.y + draggableLine.y\n                    }\n                })\n            }\n        }\n\n        // Handle for point B\n        LayerUtils.Handle {\n            id: handleB\n            x: lineLayer.observation.b.x || 0\n            y: lineLayer.observation.b.y || 0\n            size: getScaledHandleSize()\n            cursorShape: Qt.SizeAllCursor\n            visible: lineLayer.editable && lineLayer.selected\n            onMoved: {\n                _currentScene.setObservationFromName(lineLayer.name, _currentScene.selectedViewId, { \n                    b: {\n                        x: handleB.x + draggableLine.x,\n                        y: handleB.y + draggableLine.y\n                    }\n                })\n            }\n        }\n\n        // Handle for line center\n        LayerUtils.Handle {\n            id: handleCenter\n            x: lineCenter.x\n            y: lineCenter.y\n            size: getScaledHandleSize()\n            target: draggableLine\n            cursorShape: Qt.SizeAllCursor\n            visible: lineLayer.editable && lineLayer.selected\n            onMoved: {\n                _currentScene.setObservationFromName(lineLayer.name, _currentScene.selectedViewId, { \n                    a: {\n                        x: handleA.x + draggableLine.x,\n                        y: handleA.y + draggableLine.y\n                    },\n                    b: {\n                        x: handleB.x + draggableLine.x,\n                        y: handleB.y + draggableLine.y\n                    }\n                })\n            }\n        }\n    }\n}\n"
  },
  {
    "path": "meshroom/ui/qml/Shapes/Viewer/Layers/PointLayer.qml",
    "content": "import QtQuick\nimport QtQuick.Shapes\n\nimport \"Utils\" as LayerUtils\n\n/**\n* PointLayer\n*\n* @biref Allows to display and modify a 2d point.\n* @param name - the given shape name\n* @param properties - the given shape style properties\n* @param observation - the given shape position and dimensions for the current view\n* @param editable - the shape is editable\n* @param scaleRatio - the shape container scale ratio (scroll zoom)\n* @param selected - the shape is selected\n* @see BaseLayer.qml\n*/\nBaseLayer {\n    id: pointLayer\n\n    // Point size and half size\n    property real pointSize: Math.max(1.0, 12.0 * scaleRatio)\n    property real pointHalfSize: pointSize * 0.5\n\n    // Point shape\n    Shape {\n        id: draggableShape\n\n        // Center cross path\n        ShapePath {\n            fillColor: \"transparent\"\n            strokeColor: selected ? \"#ffffff\" : pointLayer.properties.color || pointLayer.defaultColor\n            strokeWidth: getScaledStrokeWidth()\n\n            PathMove { x: pointLayer.observation.x - pointSize; y: pointLayer.observation.y }\n            PathLine { x: pointLayer.observation.x + pointSize; y: pointLayer.observation.y }\n            PathMove { x: pointLayer.observation.x; y: pointLayer.observation.y - pointSize }\n            PathLine { x: pointLayer.observation.x; y: pointLayer.observation.y + pointSize }\n        }\n\n        // Selection area\n        MouseArea  {\n            x: handleCenter.x - pointSize\n            y: handleCenter.y - pointSize\n            width: pointSize * 2\n            height: pointSize * 2\n            acceptedButtons: Qt.LeftButton\n            cursorShape: pointLayer.editable ? Qt.PointingHandCursor : Qt.ArrowCursor\n            onClicked: selectionRequested()\n            enabled: pointLayer.editable && !pointLayer.selected\n        }\n\n        // Handle for point center\n        LayerUtils.Handle {\n            id: handleCenter\n            x: pointLayer.observation.x || 0\n            y: pointLayer.observation.y || 0\n            size: getScaledHandleSize()\n            target: draggableShape\n            cursorShape: Qt.SizeAllCursor\n            visible: pointLayer.editable && pointLayer.selected\n            onMoved: {\n                _currentScene.setObservationFromName(pointLayer.name, _currentScene.selectedViewId, { \n                    x: handleCenter.x + draggableShape.x, \n                    y: handleCenter.y + draggableShape.y\n                })\n            }\n        }\n\n        // Point name\n        Rectangle {\n            x: (pointLayer.observation.x || 0) + pointHalfSize\n            y: (pointLayer.observation.y || 0) + pointHalfSize\n            width: pointName.width\n            height: pointName.height\n            visible: pointLayer.editable && scaleRatio > 0.2\n            color: selected ? palette.shadow : palette.window\n\n            Text {\n                id: pointName\n                text: {\n                    if(pointLayer.properties.userName && pointLayer.properties.userName.length > 0)\n                        return pointLayer.properties.userName\n                    const lastDotIndex = pointLayer.name.lastIndexOf('.')\n                    if(lastDotIndex < 0)\n                        return pointLayer.name\n                    return pointLayer.name.substring(lastDotIndex + 1);\n                }\n                color: selected ? palette.highlightedText : palette.text\n                padding: 0\n                rightPadding: Math.max(1, 2 * scaleRatio)\n                leftPadding: rightPadding\n                wrapMode: Text.NoWrap \n                font.pixelSize: getScaledFontSize()\n            }\n        }\n    }\n}\n\n\n\n\n\n\n\n\n\n\n\n\n"
  },
  {
    "path": "meshroom/ui/qml/Shapes/Viewer/Layers/RectangleLayer.qml",
    "content": "import QtQuick\nimport QtQuick.Shapes\n\nimport \"Utils\" as LayerUtils\n\n/**\n* RectangleLayer\n*\n* @biref Allows to display and modify a rectangle.\n* @param name - the given shape name\n* @param properties - the given shape style properties\n* @param observation - the given shape position and dimensions for the current view\n* @param editable - the shape is editable\n* @param scaleRatio - the shape container scale ratio (scroll zoom)\n* @param selected - the shape is selected\n* @see BaseLayer.qml\n*/\nBaseLayer {\n    id: rectangleLayer\n\n    // Rectangle width from handleWidth position\n    property real rectangleWidth: Math.max(1.0, Math.abs(handleCenter.x- handleWidth.x) * 2)\n\n    // Rectangle height from handleHeight position\n    property real rectangleHeight: Math.max(1.0, Math.abs(handleCenter.y - handleHeight.y) * 2)\n\n    // Rectangle shape\n    Shape {\n        id : draggableRectangle\n\n        // Rectangle path \n        ShapePath {\n            fillColor: rectangleLayer.properties.fillColor || \"transparent\"\n            strokeColor: rectangleLayer.properties.strokeColor || rectangleLayer.properties.color || rectangleLayer.defaultColor\n            strokeWidth: getScaledStrokeWidth()\n\n            PathRectangle {\n                x: rectangleLayer.observation.center.x - (rectangleWidth * 0.5)\n                y: rectangleLayer.observation.center.y - (rectangleHeight * 0.5)\n                width: rectangleWidth\n                height: rectangleHeight\n            }\n        }\n\n        // Size helper path\n        ShapePath {\n            fillColor: \"transparent\"\n            strokeColor: rectangleLayer.selected ? \"#bbffffff\" : \"transparent\"\n            strokeWidth: getScaledHelperStrokeWidth()\n\n            PathMove { x: rectangleLayer.observation.center.x; y: rectangleLayer.observation.center.y }\n            PathLine { x: handleWidth.x; y: handleWidth.y }\n            PathMove { x: rectangleLayer.observation.center.x; y: rectangleLayer.observation.center.y }\n            PathLine { x: handleHeight.x; y: handleHeight.y }\n        }\n\n        // Selection area\n        MouseArea  {\n            x: handleCenter.x - rectangleWidth * 0.5\n            y: handleCenter.y - rectangleHeight * 0.5\n            width: rectangleWidth\n            height: rectangleHeight\n            acceptedButtons: Qt.LeftButton\n            cursorShape: rectangleLayer.editable ? Qt.PointingHandCursor : Qt.ArrowCursor\n            onClicked: selectionRequested()\n            enabled: rectangleLayer.editable && !rectangleLayer.selected\n        }\n\n        // Handle for rectangle center\n        LayerUtils.Handle {\n            id: handleCenter\n            x: rectangleLayer.observation.center.x || 0\n            y: rectangleLayer.observation.center.y || 0\n            size: getScaledHandleSize()\n            target: draggableRectangle\n            cursorShape: Qt.SizeAllCursor\n            visible: rectangleLayer.editable && rectangleLayer.selected\n            onMoved: {\n                _currentScene.setObservationFromName(rectangleLayer.name, _currentScene.selectedViewId, { \n                    center: {\n                        x: handleCenter.x + draggableRectangle.x,\n                        y: handleCenter.y + draggableRectangle.y,\n                    }\n                })\n            }\n        }\n\n        // Handle for rectangle width\n        LayerUtils.Handle {\n            id: handleWidth\n            x: rectangleLayer.observation.center.x + (rectangleLayer.observation.size.width * 0.5)  || 0\n            y: handleCenter.y  || 0\n            size: getScaledHandleSize()\n            yAxisEnabled: false\n            cursorShape: Qt.SizeHorCursor\n            visible: rectangleLayer.editable && rectangleLayer.selected\n            onMoved: {\n                _currentScene.setObservationFromName(rectangleLayer.name, _currentScene.selectedViewId, { \n                    size: {\n                        width: rectangleWidth,\n                        height: rectangleHeight \n                    }\n                })\n            }\n        }\n\n        // Handle for rectangle height\n        LayerUtils.Handle {\n            id: handleHeight\n            x: rectangleLayer.observation.center.x || 0\n            y: rectangleLayer.observation.center.y - (rectangleLayer.observation.size.height * 0.5)  || 0\n            size: getScaledHandleSize()\n            xAxisEnabled: false\n            cursorShape: Qt.SizeVerCursor\n            visible: rectangleLayer.editable && rectangleLayer.selected\n            onMoved: {\n                _currentScene.setObservationFromName(rectangleLayer.name, _currentScene.selectedViewId, { \n                    size: {\n                        width: rectangleWidth,\n                        height: rectangleHeight \n                    }\n                })\n            }\n        }\n    }\n}"
  },
  {
    "path": "meshroom/ui/qml/Shapes/Viewer/Layers/TextLayer.qml",
    "content": "import QtQuick\n\n/**\n* TextLayer\n*\n* @biref Allows to display a text.\n* @param name - the given shape name\n* @param properties - the given shape style properties\n* @param observation - the given shape position and dimensions for the current view\n* @param editable - the shape is editable\n* @param scaleRatio - the shape container scale ratio (scroll zoom)\n* @param selected - the shape is selected\n* @see BaseLayer.qml\n*/\nBaseLayer {\n    id: textLayer\n\n    Text {\n        x: textLayer.observation.center.x - implicitWidth * 0.5   // Center text horizontally\n        y: textLayer.observation.center.y - implicitHeight * 0.5  // Center text vertically\n        text: textLayer.observation.content || \"Undefined\"\n        color: textLayer.properties.color || textLayer.defaultColor\n        wrapMode: Text.NoWrap \n        font.family: textLayer.properties.fontFamily || \"Arial\"\n        font.pixelSize: getScaledFontSize()\n    }\n}"
  },
  {
    "path": "meshroom/ui/qml/Shapes/Viewer/Layers/Utils/Handle.qml",
    "content": "import QtQuick\n\n/**\n* Handle\n*\n* @biref Handle component to centralize handle behavior and avoid code duplication.\n* @param size - the handle display size\n* @param target - the handle drag target\n* @param xAxisEnabled - the handle x-axis is draggable\n* @param yAxisEnabled - the handle y-axis is draggable\n* @param cursorShape - the handle cursor shape\n*/\nRectangle {\n    id: root\n\n    // Handle moved signal\n    signal moved()\n    \n    // Handle display size\n    property real size : 10.0\n\n    // Handle drag target\n    property alias target: dragHandler.target\n\n    // Handle drag x-axis enabled\n    property bool xAxisEnabled : true\n\n    // Handle drag y-axis enabled\n    property bool yAxisEnabled : true\n\n    // Handle cursor shape\n    property alias cursorShape : dragHandler.cursorShape\n\n    // Handle does not have a true size\n    // Width and height should always be 0\n    width: 0\n    height: 0\n\n    // Handle hover handler\n    HoverHandler {\n        cursorShape: dragHandler.cursorShape\n        grabPermissions: PointerHandler.CanTakeOverFromAnything  \n        margin: root.size * 2 // Handle interaction area\n        enabled: root.visible\n    }\n\n    // Handle drag handler\n    DragHandler {\n        id: dragHandler\n        cursorShape: Qt.SizeBDiagCursor\n        grabPermissions: PointerHandler.CanTakeOverFromAnything \n        xAxis.enabled: root.xAxisEnabled\n        yAxis.enabled: root.yAxisEnabled\n        margin: root.size * 2 // Handle interaction area\n        onActiveChanged: { if (!active) { root.moved() } }\n        enabled: root.visible\n    }\n\n    // Handle shape\n    Rectangle {\n        x: root.size * -0.5\n        y: root.size * -0.5\n        width: root.size\n        height: root.size\n        color: \"#ffffff\"\n    }\n\n    // Handle outline\n    Rectangle {\n        x: width * -0.5\n        y: height * -0.5\n        width: 1.5 * root.size\n        height: 1.5 * root.size\n        color: \"#66666666\"\n        z: -1\n    }\n}"
  },
  {
    "path": "meshroom/ui/qml/Shapes/Viewer/ShapeViewer.qml",
    "content": "import QtQuick\n\n/**\n* ShapeViewer\n*\n* @biref A canvas to display current node shape attributes and shape files.\n* @param containerWidth  - the parent image container width\n* @param containerHeight - the parent image container height\n* @param containerScale  - the parent image container scale\n*/\nItem {\n    id: shapeViewer\n\n    // Current node\n    property var node: _currentScene ? _currentScene.selectedNode : null\n\n    // Container dimensions and scale\n    property real containerWidth: 0.0\n    property real containerHeight: 0.0\n    property real containerScale: 1.0\n\n    // Container scale ratio\n    property real scaleRatio: (1 / containerScale)\n\n    // Update ShapeViewerHelper\n    // This is usefull for new observation initialization\n    onContainerWidthChanged: { ShapeViewerHelper.containerWidth = shapeViewer.containerWidth }\n    onContainerHeightChanged: { ShapeViewerHelper.containerHeight = shapeViewer.containerHeight }\n    onContainerScaleChanged: { ShapeViewerHelper.containerScale = shapeViewer.containerScale }\n\n    // Current node shape files\n    // ShapeFilesHelper provide the model\n    Repeater {\n        model: ShapeFilesHelper.nodeShapeFiles\n        delegate: Repeater {\n            model: object.shapes\n            delegate: ShapeViewerLayer {\n                active: object.isVisible\n                scaleRatio: shapeViewer.scaleRatio\n                name: object.name\n                type: object.type\n                properties: object.properties\n                observation: object.observation\n                editable: false\n            }\n        }\n    }\n    \n    // Current node shape attributes\n    // Node attributes as the model\n    Repeater {\n        model: node.attributes\n        delegate: ShapeViewerAttributeLoader {\n            attribute: object\n            scaleRatio: shapeViewer.scaleRatio\n        }\n    }\n\n    // Reset selection\n    TapHandler {\n        acceptedButtons: Qt.LeftButton\n        gesturePolicy: TapHandler.WithinBounds\n        onTapped: { ShapeViewerHelper.selectedShapeName = \"\" }\n    }\n}"
  },
  {
    "path": "meshroom/ui/qml/Shapes/Viewer/ShapeViewerAttributeLayer.qml",
    "content": "import QtQuick\n\n/**\n* ShapeViewerAttributeLayer\n*\n* @biref Shape attribute layer loader.\n* @param shapeAttribute - the given shape attribute\n* @param isLinkChild - Whether the given attribute is a child of a linked attribute\n* @param scaleRatio - the container scale ratio (scroll zoom)\n*/\nLoader {\n\n    // Properties\n    property var shapeAttribute\n    property bool isLinkChild: false\n    property real scaleRatio: 1.0\n\n    // Source component\n    sourceComponent: shapeAttributeLayerComponent\n\n    // Reload source component\n    // When attribute observations changed (signal)\n    // For now, ShapeLayer should be re-build when observation changed\n    Connections {\n        target: shapeAttribute.geometry\n        function onObservationsChanged() {\n            sourceComponent = null\n            sourceComponent = shapeAttributeLayerComponent\n        }\n    }\n\n    // Shape attribute layer component\n    Component {\n        id: shapeAttributeLayerComponent\n        Loader {\n            sourceComponent: ShapeViewerLayer {\n                scaleRatio: shapeViewer.scaleRatio\n                name: shapeAttribute.fullName\n                type: shapeAttribute.type\n                properties: ({\"color\" : shapeAttribute.userColor, \"userName\" : shapeAttribute.userName})\n                observation: shapeAttribute.geometry.getObservation(_currentScene ? _currentScene.selectedViewId : \"-1\")\n                editable: shapeAttribute.enabled && !shapeAttribute.isLink && !isLinkChild\n            }\n        }\n    }\n}\n\n"
  },
  {
    "path": "meshroom/ui/qml/Shapes/Viewer/ShapeViewerAttributeLoader.qml",
    "content": "import QtQuick\n\n/**\n* ShapeViewerAttributeLoader\n*\n* @biref ShapeViewer attribute loader.\n* @param attribute - the given attribute (ShapeAttribute or ShapeListAttribute)\n* @param scaleRatio - the container scale ratio (scroll zoom)\n*/\nLoader {\n    id: attributeLoader\n\n    // Properties\n    property var attribute\n    property real scaleRatio: 1.0\n\n    // Attribute should be shape or shape list\n    // Attribute should be visible\n    active: attribute.hasDisplayableShape && attribute.isVisible \n\n    // Source component\n    sourceComponent: {\n        if(attribute.type === \"ShapeList\")\n            return shapeListAttributeComponent\n        return shapeAttributeComponent\n    }\n\n    // Shape attribute component\n    Component {\n        id: shapeAttributeComponent\n        ShapeViewerAttributeLayer {\n            active: !attribute.geometry.isDefault\n            shapeAttribute: attribute\n            scaleRatio: attributeLoader.scaleRatio\n        }\n    }\n\n    // Shape list attribute component\n    Component {\n        id: shapeListAttributeComponent\n        Repeater {\n            model: attribute.value\n            delegate: ShapeViewerAttributeLayer {\n                active: object.isVisible && !object.geometry.isDefault\n                shapeAttribute: object\n                isLinkChild: attribute.isLink\n                scaleRatio: attributeLoader.scaleRatio\n            }\n        }\n    }\n}"
  },
  {
    "path": "meshroom/ui/qml/Shapes/Viewer/ShapeViewerLayer.qml",
    "content": "import QtQuick\nimport \"Layers\" as ShapeViewerLayers\n\n/**\n* ShapeViewerLayer\n*\n* @biref Load the corresponding shape layer.\n* @param type - the given shape type\n* @param name - the given shape name\n* @param properties - the given shape style properties\n* @param observation - the given shape position and dimensions for the current view\n* @param editable - the shape is editable\n* @param scaleRatio - the container scale ratio (scroll zoom)\n*/\nLoader {\n    id: layerLoader\n\n    // Properties\n    property string type\n    property string name\n    property var properties\n    property var observation\n    property bool editable: false\n    property real scaleRatio: 1.0\n\n    // Source component\n    sourceComponent: {\n        if (!properties || !observation)\n            return;\n        switch (type) {\n            case \"Point2d\":   return pointLayerComponent\n            case \"Line2d\":    return lineLayerComponent\n            case \"Circle\":    return circleLayerComponent\n            case \"Rectangle\": return rectangleLayerComponent\n            case \"Text\":      return textLayerComponent\n        }\n    }\n    \n    // PointLayer component\n    Component { \n        id: pointLayerComponent\n        ShapeViewerLayers.PointLayer {\n            name: layerLoader.name\n            properties: layerLoader.properties\n            observation: layerLoader.observation\n            editable: layerLoader.editable\n            scaleRatio: layerLoader.scaleRatio\n        } \n    }\n\n    // LineLayer component\n    Component { \n        id: lineLayerComponent\n        ShapeViewerLayers.LineLayer {\n            name: layerLoader.name\n            properties: layerLoader.properties\n            observation: layerLoader.observation\n            editable: layerLoader.editable\n            scaleRatio: layerLoader.scaleRatio\n        } \n    }\n\n    // CircleLayer component\n    Component { \n        id: circleLayerComponent\n        ShapeViewerLayers.CircleLayer {\n            name: layerLoader.name\n            properties: layerLoader.properties\n            observation: layerLoader.observation\n            editable: layerLoader.editable\n            scaleRatio: layerLoader.scaleRatio\n        }\n    }\n\n    // RectangleLayer component\n    Component {\n        id: rectangleLayerComponent\n        ShapeViewerLayers.RectangleLayer {\n            name: layerLoader.name\n            properties: layerLoader.properties\n            observation: layerLoader.observation\n            editable: layerLoader.editable\n            scaleRatio: layerLoader.scaleRatio\n        } \n    }\n\n    // TextLayer component\n    Component { \n        id: textLayerComponent\n        ShapeViewerLayers.TextLayer {\n            name: layerLoader.name\n            properties: layerLoader.properties\n            observation: layerLoader.observation\n            editable: layerLoader.editable\n            scaleRatio: layerLoader.scaleRatio\n        } \n    }\n}\n"
  },
  {
    "path": "meshroom/ui/qml/Shapes/qmldir",
    "content": "module Shapes\n\nShapeEditor 1.0 Editor/ShapeEditor.qml\nShapeViewer 1.0 Viewer/ShapeViewer.qml"
  },
  {
    "path": "meshroom/ui/qml/Utils/Clipboard.qml",
    "content": "pragma Singleton\nimport Meshroom.Helpers 1.0\n\n/**\n * Clipboard singleton object to copy values to paste buffer.\n */\n\nClipboardHelper {\n\n}\n"
  },
  {
    "path": "meshroom/ui/qml/Utils/Colors.qml",
    "content": "pragma Singleton\nimport QtQuick\nimport QtQuick.Controls\n\n/**\n * Singleton that gathers useful colors, shades and system palettes.\n */\n\nQtObject {\n    property SystemPalette sysPalette: SystemPalette {}\n    property SystemPalette disabledSysPalette: SystemPalette { colorGroup: SystemPalette.Disabled }\n\n    readonly property color green: \"#4CAF50\"\n    readonly property color orange: \"#FF9800\"\n    readonly property color yellow: \"#FFEB3B\"\n    readonly property color red: \"#F44336\"\n    readonly property color crimson: \"#DC143C\"\n    readonly property color firebrick: \"#B22222\"\n    readonly property color blue: \"#03A9F4\"\n    readonly property color cyan: \"#00BCD4\"\n    readonly property color pink: \"#E91E63\"\n    readonly property color lime: \"#CDDC39\"\n    readonly property color grey: \"#555555\"\n    readonly property color lightgrey: \"#999999\"\n    readonly property color darkpurple: \"#5c4885\"\n\n    readonly property var statusColors: {\n        \"NONE\": \"transparent\",\n        \"SUBMITTED\": cyan,\n        \"RUNNING\": orange,\n        \"ERROR\": red,\n        \"SUCCESS\": green,\n        \"STOPPED\": pink,\n        \"INPUT\": \"transparent\"\n    }\n\n    readonly property var ghostColors: {\n        \"SUBMITTED\": Qt.darker(cyan, 1.5),\n        \"RUNNING\": Qt.darker(orange, 1.5),\n        \"STOPPED\": Qt.darker(pink, 1.5)\n    }\n\n    readonly property var statusColorsExternOverrides: {\n        \"SUBMITTED\": \"#2196F3\"\n    }\n\n    readonly property var durationColorScale: [\n        {\"time\": 0, \"color\": grey},\n        {\"time\": 5, \"color\": green},\n        {\"time\": 20, \"color\": yellow},\n        {\"time\": 90, \"color\": red}\n    ]\n\n    function getChunkColor(chunk, overrides) {\n        if (chunk === undefined)\n            return \"transparent\"\n        if (overrides && chunk.statusName in overrides) {\n            return overrides[chunk.statusName]\n        } else if (chunk.execModeName === \"EXTERN\" && chunk.statusName in statusColorsExternOverrides) {\n            return statusColorsExternOverrides[chunk.statusName]\n        } else if (chunk.nodeName !== chunk.statusNodeName && chunk.statusName in ghostColors) {\n            return ghostColors[chunk.statusName]\n        } else if (chunk.statusName in statusColors) {\n            return statusColors[chunk.statusName]\n        }\n        console.warn(\"Unknown status : \" + chunk.status)\n        return \"magenta\"\n    }\n    \n    function getNodeColor(node, overrides) {\n        if (node === undefined)\n            return \"transparent\"\n        if (overrides && node.globalStatus in overrides) {\n            return overrides[node.globalStatus]\n        } else if (node.globalExecMode === \"EXTERN\" && node.globalStatus in statusColorsExternOverrides) {\n            return statusColorsExternOverrides[node.globalStatus]\n        } else if (node.name !== node.nodeStatusNodeName && node.globalStatus in ghostColors) {\n            return ghostColors[node.globalStatus]\n        } else if (node.globalStatus in statusColors) {\n            return statusColors[node.globalStatus]\n        }\n        console.warn(\"Unknown status : \" + node.globalStatus)\n        return \"magenta\"\n    }\n\n    function toRgb(color) {\n        return [\n            parseInt(color.toString().substr(1, 2), 16) / 255, \n            parseInt(color.toString().substr(3, 2), 16) / 255, \n            parseInt(color.toString().substr(5, 2), 16) / 255\n        ]\n    }\n\n    function interpolate(c1, c2, u) {\n        let rgb1 = toRgb(c1)\n        let rgb2 = toRgb(c2)\n        return Qt.rgba(\n            rgb1[0] * (1 - u) + rgb2[0] * u,\n            rgb1[1] * (1 - u) + rgb2[1] * u,\n            rgb1[2] * (1 - u) + rgb2[2] * u\n        )\n    }\n\n    function durationColor(t) {\n        if (t < durationColorScale[0].time) {\n            return durationColorScale[0].color\n        }\n        if (t > durationColorScale[durationColorScale.length-1].time) {\n            return durationColorScale[durationColorScale.length-1].color\n        }\n        for (let idx = 1; idx < durationColorScale.length; idx++) {\n            if (t < durationColorScale[idx].time) {\n                let u = (t - durationColorScale[idx - 1].time) / (durationColorScale[idx].time - durationColorScale[idx - 1].time)\n                return interpolate(durationColorScale[idx - 1].color, durationColorScale[idx].color, u)\n            }\n        }\n    }\n}\n"
  },
  {
    "path": "meshroom/ui/qml/Utils/ExifOrientation.qml",
    "content": "pragma Singleton\nimport QtQuick\n\n/**\n * Singleton that defines utility functions for supporting exif orientation tags.\n *\n * If you are looking for a way to create a Loader that supports exif orientation tags,\n * you can directly use ExifOrientedViewer instead.\n *\n * However if you want to apply an exif orientation tag to another type of QML component,\n * you will need to redefine its transform property using the utility methods given below.\n */\nQtObject {\n\n    function rotation(orientationTag) {\n        switch(orientationTag) {\n            case \"3\":\n                return 180;\n            case \"4\":\n                return 180;\n            case \"5\":\n                return 90;\n            case \"6\":\n                return 90;\n            case \"7\":\n                return -90;\n            case \"8\":\n                return -90;\n            default:\n                return 0;\n        }\n    }\n\n    function xscale(orientationTag) {\n        switch(orientationTag) {\n            case \"2\":\n                return -1;\n            case \"4\":\n                return -1;\n            case \"5\":\n                return -1;\n            case \"7\":\n                return -1;\n            default:\n                return 1;\n        }\n    }\n\n}\n"
  },
  {
    "path": "meshroom/ui/qml/Utils/ExpressionTextField.qml",
    "content": "import QtQuick\nimport QtQuick.Controls\n\nTextField {\n    id: root\n\n    // evaluated numeric value (NaN if invalid)\n    // It helps keeping the connection that text has so that we do not lose ability to undo/reset\n    property bool exprTextChanged: false\n    property real evaluatedValue: 0\n\n    property bool hasExprError: false\n    property bool isInt: false\n\n    // Overlay for error state (red border on top of default background)\n    Rectangle {\n        anchors.fill: parent\n        radius: 4\n        border.color: \"red\"\n        color: \"transparent\"\n        visible: root.hasExprError\n        z: 1\n    }\n\n    function raiseError() {\n        hasExprError = true\n    }\n\n    function clearError() {\n        hasExprError = false\n    }\n\n    function getEvalExpression(_text) {\n        var [_res, _err] = _currentScene.evaluateMathExpression(_text)\n        if (_err == false) {\n            if (isInt)\n                _res = Math.round(_res)\n            return _res\n        } else {\n            console.error(\"Error: Expression\", _text, \"is invalid\")\n            return NaN\n        }\n    }\n\n    function refreshStatus() {\n        if (isNaN(getEvalExpression(root.text))) {\n            raiseError()\n        } else {\n            clearError()\n        }\n    }\n\n    function updateExpression() {\n        var previousEvaluatedValue = evaluatedValue\n        var result = getEvalExpression(root.text)\n        if (!isNaN(result)) {\n            evaluatedValue = result\n            clearError()\n        } else {\n            evaluatedValue = previousEvaluatedValue\n            raiseError()\n        }\n        exprTextChanged = false\n    }\n\n    // onAccepted and onEditingFinished will break the bindings to text\n    // so if used on fields that needs to be driven by sliders or other qml element,\n    // the binding needs to be restored\n    // No need to restore the binding if the expression has an error because we do not break it\n\n    onAccepted: {\n        if (exprTextChanged)\n        {\n            updateExpression()\n            if (!hasExprError && !isNaN(evaluatedValue)) {\n                // Commit the result value to the text field\n                if (isInt)\n                    root.text = Number(evaluatedValue).toFixed(0)\n                else\n                    root.text = Number(evaluatedValue)\n            }\n        }\n    }\n\n    onEditingFinished: {\n        if (exprTextChanged)\n        {\n            updateExpression()\n            if (!hasExprError && !isNaN(evaluatedValue)) {\n                if (isInt)\n                    root.text = Number(evaluatedValue).toFixed(0)\n                else\n                    root.text = Number(evaluatedValue)\n            }\n        }\n    }\n\n    onTextChanged: {\n        if (!activeFocus && exprTextChanged) {\n            refreshStatus()\n        } else {\n            exprTextChanged = true\n        }\n    }\n\n    Component.onDestruction: {\n        if (exprTextChanged) {\n            root.accepted()\n        }\n    }\n}\n"
  },
  {
    "path": "meshroom/ui/qml/Utils/Filepath.qml",
    "content": "pragma Singleton\nimport Meshroom.Helpers 1.0\n\nFilepathHelper {\n\n}\n"
  },
  {
    "path": "meshroom/ui/qml/Utils/Scene3DHelper.qml",
    "content": "pragma Singleton\nimport Meshroom.Helpers 1.0\n\nScene3DHelper {\n}\n"
  },
  {
    "path": "meshroom/ui/qml/Utils/SortFilterDelegateModel.qml",
    "content": "import QtQuick\nimport QtQml.Models\nimport QtQuick.Controls\n\n/**\n * SortFilderDelegateModel adds sorting and filtering capabilities on a source model.\n *\n * The way model data is accessed can be overridden by redefining the modelData function.\n * This is useful if the value is not directly accessible from the model and needs\n * some extra logic.\n *\n * Regarding filtering, each filter is defined with a role to filter on and a value to match.\n * Filters can be accumulated in a 2D-array, which is evaluated with the following rules: \n * - on the 2nd dimension we map respectFilter and reduce with logical OR\n * - on the 1st dimension we map respectFilter and reduce with logical AND.\n * Filtering behavior can also be overridden by redefining the respectFilter function.\n *\n * Based on http://doc.qt.io/qt-5/qtquick-tutorials-dynamicview-dynamicview4-example.html\n */\n\nDelegateModel {\n    id: sortFilterModel\n\n    property string sortRole: \"\"                /// The role to use for sorting\n    property int sortOrder: Qt.AscendingOrder   /// The sorting order\n    property var filters: []                    /// Filter format: {role: \"roleName\", value: \"filteringValue\"}\n\n    onSortRoleChanged: invalidateSort()\n    onSortOrderChanged: invalidateSort()\n    onFiltersChanged: invalidateFilters()\n\n    // Display \"filtered\" group\n    filterOnGroup: \"filtered\"\n    // Don't include elements in \"items\" group by default as they must fall in the \"unsorted\" group\n    items.includeByDefault: false\n\n    groups: [\n        // Group for temporarily storing items before sorting\n        DelegateModelGroup {\n            id: unsortedItems\n\n            name: \"unsorted\"\n            includeByDefault: true\n            // If the source model changes, perform sorting and filtering\n            onChanged: {\n                // No sorting: move everything from unsorted to sorted group\n                if(sortRole == \"\") {\n                    unsortedItems.setGroups(0, unsortedItems.count, [\"items\"])\n                } else {\n                    sort()\n                }\n                // Perform filter invalidation in both cases\n                invalidateFilters()\n            }\n        },\n        // Group for storing filtered items\n        DelegateModelGroup {\n            id: filteredItems\n            name: \"filtered\"\n        }\n    ]\n\n    /// Get data from model for 'roleName'\n    function modelData(item, roleName) {\n        return item.model[roleName]\n    }\n\n    /// Get the index of the first element which matches 'value' for the given 'roleName'\n    function find(value, roleName) {\n        for (var i = 0; i < filteredItems.count; ++i) {\n            if (modelData(filteredItems.get(i), roleName) == value)\n                return i\n        }\n        return -1\n    }\n\n    /**\n     * Return whether 'value' respects 'filter' condition\n     *\n     * The test is based on the value's type:\n     *   - String: check if 'value' contains 'filter' (case insensitive)\n     *   - any other type: test for equality (===)\n     *\n     * TODO: add case sensitivity / whole word options for Strings\n     */\n    function respectFilter(value, filter) {\n        if (filter === undefined) {\n            return true;\n        }\n        switch (value.constructor.name) {\n            case \"String\":\n                return value.toLowerCase().indexOf(filter.toLowerCase()) > -1\n            default:\n                return value === filter\n        }\n    }\n\n    /// Apply respectFilter mapping and logical AND/OR reduction on filters\n    function respectFilters(item) {\n        let cond = (filter => respectFilter(modelData(item, filter.role), filter.value))\n        return filters.every(x => Array.isArray(x) ? x.some(cond) : cond(x))\n    }\n\n    /// Reverse sort order (toggle between Qt.AscendingOrder / Qt.DescendingOrder)\n    function reverseSortOrder() {\n        sortOrder = sortOrder == Qt.AscendingOrder ? Qt.DescendingOrder : Qt.AscendingOrder\n    }\n\n    property var lessThan: [\n        function(left, right) {\n            return modelData(left, sortRole) < modelData(right, sortRole)\n        }\n    ]\n\n    function invalidateSort() {\n        if (!sortFilterModel.model || !sortFilterModel.model.count)\n            return;\n\n        // Move everything from \"items\" to \"unsorted\", will trigger \"unsorted\" DelegateModelGroup 'changed' signal\n        items.setGroups(0, items.count, [\"unsorted\"])\n    }\n\n    /// Invalidate filtering\n    function invalidateFilters() {\n        for (var i = 0; i < items.count; ++i) {\n            // If the property value contains filterText, add it to the filtered group\n            if (respectFilters(items.get(i))) {\n                items.addGroups(items.get(i), 1, \"filtered\")\n            } else {  // Otherwise, remove it from the filtered group\n                items.removeGroups(items.get(i), 1, \"filtered\")\n            }\n        }\n    }\n\n    /// Compute insert position of 'item' based on the value of its sortProperty\n    function insertPosition(lessThan, item) {\n        var lower = 0\n        var upper = items.count\n        while (lower < upper) {\n            var middle = Math.floor(lower + (upper - lower) / 2)\n            var result = lessThan(item, items.get(middle))\n            if (sortOrder == Qt.DescendingOrder) {\n                result = !result\n            }\n\n            if (result) {\n                upper = middle\n            } else {\n                lower = middle + 1\n            }\n        }\n        return lower\n    }\n\n    /// Perform model sorting\n    function sort() {\n        while (unsortedItems.count > 0) {\n            var item = unsortedItems.get(0)\n            var index = insertPosition(lessThan[0], item)\n            item.groups = [\"items\"]\n            items.move(item.itemsIndex, index)\n        }\n        // If some items were actually sorted, filter will be correctly invalidated\n        // as unsortedGroup 'changed' signal will be triggered\n    }\n}\n"
  },
  {
    "path": "meshroom/ui/qml/Utils/Transformations3DHelper.qml",
    "content": "pragma Singleton\nimport Meshroom.Helpers 1.0\n\nTransformations3DHelper {\n}\n"
  },
  {
    "path": "meshroom/ui/qml/Utils/errorHandler.js",
    "content": ".pragma library\n\n/**\n * Analyse raised errors.\n * Works only if errors are written with this specific syntax:\n * [Context] ErrorType: ErrorMessage\n *\n * Maybe it would be better to handle errors on Python side but it should be harder to handle Dialog customization\n */\nfunction analyseError(error) {\n    const msg = error.toString()\n\n    // The use of [^] is like . but it takes in count every character including \\n (works as a double negation)\n    // Group 1: Context\n    // Group 2: ErrorType\n    // Group 3: ErrorMessage\n    const regex = /\\[(.*)\\]\\s(.*):([^]*)/\n    if (!regex.test(msg))\n        return {\n            context: \"\",\n            type: \"\",\n            msg: \"\"\n        }\n\n    const data = regex.exec(msg)\n\n    return {\n        context: data[1],\n        type: data[2],\n        msg: data[3].startsWith(\"\\n\") ? data[3].slice(1) : data[3]\n    }\n}\n"
  },
  {
    "path": "meshroom/ui/qml/Utils/format.js",
    "content": ".pragma library\n\nfunction intToString(v) {\n    // Use EN locale to get comma separated thousands\n    // + remove automatically added trailing decimals\n    // (this 'toLocaleString' does not take any option)\n    return v.toLocaleString(Qt.locale('en-US')).split('.')[0]\n}\n\n// Convert a plain text to an html escaped string.\nfunction plainToHtml(t) {\n    var escaped = t.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')  // Escape text\n    return escaped.replace(/\\n/g, '<br>')  // Replace line breaks\n}\n\nfunction divmod(x, y) {\n    // Perform the division and get the quotient\n    const quotient = Math.floor(x / y)\n    // Compute the remainder\n    const remainder = x % y\n    return [quotient, remainder]\n}\n\nfunction sec2timeHMS(totalSeconds) {\n    const [totalMinutes, seconds] = divmod(totalSeconds, 60.0)\n    const [hours, minutes] = divmod(totalMinutes, 60.0)\n\n    return {\n        hours: hours,\n        minutes: minutes,\n        seconds: seconds\n    }\n}\n\nfunction sec2timecode(timeSeconds) {\n    var pad = function(num, size) { return ('000' + num).slice(size * -1) }\n    var timeObj = sec2timeHMS(Math.round(timeSeconds))\n    var timeStr = pad(timeObj.hours, 2) + ':' + pad(timeObj.minutes, 2) + ':' + pad(timeObj.seconds, 2)\n    return timeStr\n}\n\nfunction sec2timeStr(timeSeconds) {\n    // Need to decide the rounding precision first to propagate the right values\n    if (timeSeconds >= 60.0) {\n        timeSeconds = Math.round(timeSeconds)\n    } else {\n        timeSeconds = parseFloat(timeSeconds.toFixed(2))\n    }\n    var timeObj = sec2timeHMS(timeSeconds)\n    var timeStr = \"\"\n    if (timeObj.hours > 0) {\n        timeStr += timeObj.hours + \"h\"\n    }\n    if (timeObj.hours > 0 || timeObj.minutes > 0) {\n        timeStr += timeObj.minutes + \"m\"\n    }\n    if (timeObj.hours === 0) {\n        // Seconds only matter if the elapsed time is less than 1 hour\n        if (timeObj.minutes === 0) {\n            // If less than a minute, keep millisecond precision\n            timeStr += timeObj.seconds.toFixed(2) + \"s\"\n        } else {\n            // If more than a minute, do not need more precision than seconds\n            timeStr += Math.round(timeObj.seconds) + \"s\"\n        }\n    }\n    return timeStr\n}\n\nfunction GB2GBMBKB(GB) {\n    // Convert GB to GB, MB, KB\n    var GBInt = Math.floor(GB)\n    var MB = Math.floor((GB - GBInt) * 1024)\n    var KB = Math.floor(((GB - GBInt) * 1024 - MB) * 1024)\n    return {\n        GB: GBInt,\n        MB: MB,\n        KB: KB\n    }\n}\n\nfunction GB2SizeStr(GB) {\n    // Convert GB to a human readable size string\n    // e.g. 1.23GB, 456MB, 789KB\n    // We only use one unit at a time\n    var sizeObj = GB2GBMBKB(GB)\n    var sizeStr = \"\"\n    if (sizeObj.GB > 0) {\n        sizeStr += sizeObj.GB\n        if (sizeObj.MB > 0 && sizeObj.GB < 10) {\n            sizeStr += \".\" + Math.floor(sizeObj.MB / 1024 * 1000)\n        }\n        sizeStr += \"GB\"\n    } else if (sizeObj.MB > 0) {\n        sizeStr = sizeObj.MB\n        if (sizeObj.KB > 0 && sizeObj.MB < 10) {\n            sizeStr += \".\" + Math.floor(sizeObj.KB / 1024 * 1000)\n        }\n        sizeStr += \"MB\"\n    } else if (sizeObj.GB === 0 && sizeObj.MB === 0) {\n        sizeStr += sizeObj.KB + \"KB\"\n    }\n    return sizeStr\n}\n"
  },
  {
    "path": "meshroom/ui/qml/Utils/qmldir",
    "content": "module Utils\n\nsingleton Colors 1.0 Colors.qml\nSortFilterDelegateModel 1.0 SortFilterDelegateModel.qml\nRequest 1.0 request.js\nFormat 1.0 format.js\nErrorHandler 1.0 errorHandler.js\nsingleton ExifOrientation 1.0 ExifOrientation.qml\n# using singleton here causes random crash at application exit\n# singleton Clipboard 1.0 Clipboard.qml\n# singleton Filepath 1.0 Filepath.qml\n# singleton Scene3DHelper 1.0 Scene3DHelper.qml\n# singleton Transformations3DHelper 1.0 Transformations3DHelper.qml\nExpressionTextField 1.0 ExpressionTextField.qml\n"
  },
  {
    "path": "meshroom/ui/qml/Utils/request.js",
    "content": ".pragma library\n\n/**\n * Perform 'GET' request on url, and bind 'callback' to onreadystatechange (with XHR object as parameter).\n */\nfunction get(url, callback) {\n    var xhr = new XMLHttpRequest();\n    xhr.onreadystatechange = function() { callback(xhr) }\n    xhr.open(\"GET\", url)\n    xhr.send()\n}\n"
  },
  {
    "path": "meshroom/ui/qml/Viewer/CameraResponseGraph.qml",
    "content": "import QtQuick\nimport QtQuick.Controls\nimport QtQuick.Layouts\n\nimport QtCharts\n\nimport Charts 1.0\nimport Controls 1.0\nimport DataObjects 1.0\n\nFloatingPane {\n    id: root\n\n    property var responsePath: null\n    property color textColor: Colors.sysPalette.text\n\n    clip: true\n    padding: 4\n\n    CsvData {\n        id: csvData\n        filepath: responsePath\n    }\n\n    // To avoid interaction with components in background\n    MouseArea {\n        anchors.fill: parent\n        acceptedButtons: Qt.LeftButton | Qt.RightButton | Qt.MiddleButton\n        onPressed: {}\n        onReleased: {}\n        onWheel: {}\n    }\n\n    // Note: We need to use csvData.getNbColumns() slot instead of the csvData.nbColumns property to avoid a crash on linux.\n    property bool crfReady: csvData && csvData.ready && (csvData.getNbColumns() >= 4)\n    onCrfReadyChanged: {\n        if (crfReady) {\n            redCurve.clear()\n            greenCurve.clear()\n            blueCurve.clear()\n            csvData.getColumn(1).fillChartSerie(redCurve)\n            csvData.getColumn(2).fillChartSerie(greenCurve)\n            csvData.getColumn(3).fillChartSerie(blueCurve)\n        } else {\n            redCurve.clear()\n            greenCurve.clear()\n            blueCurve.clear()\n        }\n    }\n    Item {\n        anchors.horizontalCenter: parent.horizontalCenter\n        anchors.verticalCenter: parent.verticalCenter\n        anchors.horizontalCenterOffset: -responseChart.width/2\n        anchors.verticalCenterOffset: -responseChart.height/2\n\n        InteractiveChartView {\n            id: responseChart\n            width: root.width > 400 ? 400 : (root.width < 350 ? 350 : root.width)\n            height: width * 0.75\n\n            title: \"Camera Response Function (CRF)\"\n            legend.visible: false\n            antialiasing: true\n\n            ValueAxis {\n                id: valueAxisX\n                labelFormat: \"%i\"\n                titleText: \"Camera Brightness\"\n                min: crfReady ? csvData.getColumn(0).getFirst() : 0\n                max: crfReady ? csvData.getColumn(0).getLast() : 1\n            }\n            ValueAxis {\n                id: valueAxisY\n                titleText: \"Normalized Radiance\"\n                min: 0.0\n                max: 1.0\n            }\n\n            // We cannot use a Repeater with these Components so we need to instantiate them one by one\n            LineSeries {\n                // Red curve\n                id: redCurve\n                axisX: valueAxisX\n                axisY: valueAxisY\n                name: crfReady ? csvData.getColumn(1).title : \"\"\n                color: name.toLowerCase()\n            }\n            LineSeries {\n                // Green curve\n                id: greenCurve\n                axisX: valueAxisX\n                axisY: valueAxisY\n                name: crfReady ? csvData.getColumn(2).title : \"\"\n                color: name.toLowerCase()\n            }\n            LineSeries {\n                // Blue curve\n                id: blueCurve\n                axisX: valueAxisX\n                axisY: valueAxisY\n                name: crfReady ? csvData.getColumn(3).title : \"\"\n                color: name.toLowerCase()\n            }\n        }\n\n        Item {\n            id: btnContainer\n\n            anchors.bottom: responseChart.bottom\n            anchors.bottomMargin: 35\n            anchors.left: responseChart.left\n            anchors.leftMargin: responseChart.width * 0.15\n\n            RowLayout {\n                ChartViewCheckBox {\n                    text: \"ALL\"\n                    color: textColor\n                    checkState: legend.buttonGroup.checkState\n                    onClicked: {\n                        const _checked = checked\n                        for (let i = 0; i < responseChart.count; ++i) {\n                            responseChart.series(i).visible = _checked\n                        }\n                    }\n                }\n\n                ChartViewLegend {\n                    id: legend\n                    chartView: responseChart\n                }\n            }\n        }\n    }\n}\n"
  },
  {
    "path": "meshroom/ui/qml/Viewer/CircleGizmo.qml",
    "content": "import QtQuick\n\nItem {\n    id: root\n\n    property bool readOnly: false\n\n    signal moved(real xoffset, real yoffset)\n    signal incrementRadius(real radiusOffset)\n\n    // Circle\n    property real circleX: 0.\n    property real circleY: 0.\n    Rectangle {\n        id: circle\n\n        width: radius * 2\n        height: width\n\n        x: circleX + (root.width - width) / 2\n        y: circleY + (root.height - height) / 2\n\n        color: \"transparent\"\n        border.width: 5\n        border.color: readOnly ? \"green\" : \"yellow\"\n\n        // Cross to visualize the circle center\n        Rectangle {\n            color: parent.border.color\n            anchors.centerIn: parent\n            width: parent.width * 0.2\n            height: parent.border.width * 0.5\n        }\n        Rectangle {\n            color: parent.border.color\n            anchors.centerIn: parent\n            width: parent.border.width * 0.5\n            height: parent.height * 0.2\n        }\n\n        Loader {\n            anchors.fill: parent\n            active: !root.readOnly\n\n            sourceComponent: MouseArea {\n                id: mArea\n                anchors.fill: parent\n                cursorShape: root.readOnly ? Qt.ArrowCursor : (controlModifierEnabled ? Qt.SizeBDiagCursor : (pressed ? Qt.ClosedHandCursor : Qt.OpenHandCursor))\n                propagateComposedEvents: true\n\n                property bool controlModifierEnabled: false\n                onPositionChanged: function(mouse) {\n                    mArea.controlModifierEnabled = (mouse.modifiers & Qt.ControlModifier)\n                    mouse.accepted = false\n                }\n                acceptedButtons: Qt.LeftButton\n                hoverEnabled: true\n                drag.target: circle\n\n                drag.onActiveChanged: {\n                    if (!drag.active) {\n                        root.moved(circle.x - (root.width - circle.width) / 2, circle.y - (root.height - circle.height) / 2)\n                    }\n                }\n                onPressed: {\n                    forceActiveFocus()\n                }\n                onWheel: function(wheel) {\n                    mArea.controlModifierEnabled = (wheel.modifiers & Qt.ControlModifier)\n                    if (wheel.modifiers & Qt.ControlModifier) {\n                        root.incrementRadius(wheel.angleDelta.y / 120.0)\n                        wheel.accepted = true\n                    } else {\n                        wheel.accepted = false\n                    }\n                }\n            }\n        }\n    }\n    property alias circleRadius: circle.radius\n    property alias circleBorder: circle.border\n\n    /*\n    // visualize top-left corner for debugging purpose\n    Rectangle {\n        color: \"red\"\n        width: 500\n        height: 50\n    }\n    Rectangle {\n        color: \"red\"\n        width: 50\n        height: 500\n    }\n    */\n}\n"
  },
  {
    "path": "meshroom/ui/qml/Viewer/ColorCheckerEntity.qml",
    "content": "import QtQuick\n\nItem {\n    id: root\n\n    // Required for perspective transform\n    property real sizeX: 1675.0  // Might be overridden in ColorCheckerViewer\n    property real sizeY: 1125.0  // Might be overridden in ColorCheckerViewer\n\n    property var colors: null\n    property var window: null\n\n\n    Rectangle {\n        id: canvas\n        anchors.centerIn: parent\n\n        width: parent.sizeX\n        height: parent.sizeY\n\n        color: \"transparent\"\n        border.width: Math.max(1, (4.0 / zoom))\n        border.color: \"red\"\n\n        transformOrigin: Item.TopLeft\n        transform: Matrix4x4 {\n            id: transformation\n            matrix: Qt.matrix4x4()\n        }\n    }\n\n    function applyTransform(m) {\n        transformation.matrix = Qt.matrix4x4(\n                m[0][0], m[0][1],  0, m[0][2],\n                m[1][0], m[1][1],  0, m[1][2],\n                      0,       0,  1,       0,\n                m[2][0], m[2][1],  0, m[2][2])\n    }\n}\n"
  },
  {
    "path": "meshroom/ui/qml/Viewer/ColorCheckerPane.qml",
    "content": "import QtQuick\nimport QtQuick.Layouts\n\nimport Controls 1.0\n\nFloatingPane {\n    id: root\n\n    property var colors: null\n\n    clip: true\n    padding: 4\n    anchors.rightMargin: 0\n\n    ColumnLayout {\n        anchors.fill: parent\n\n        Grid {\n            id: grid\n\n            spacing: 5\n            x: spacing\n            y: spacing\n            rows: 4\n            columns: 6\n\n            Repeater {\n                model: root.colors\n\n                Rectangle {\n                    id: cell\n                    width: root.width / grid.columns - grid.spacing * (grid.columns + 1) / grid.columns\n                    height: root.height / grid.rows - grid.spacing * (grid.rows + 1) / grid.rows\n                    color: Qt.rgba(modelData.r, modelData.g, modelData.b, 1.0)\n                }\n            }\n        }\n    }\n}\n"
  },
  {
    "path": "meshroom/ui/qml/Viewer/ColorCheckerViewer.qml",
    "content": "import QtQuick\n\nItem {\n    id: root\n\n    property url source: undefined\n    property var json: null\n    property var viewpoint: null\n    property real zoom: 1.0\n\n    // Required for perspective transform\n    // Match theoretical values in AliceVision\n    // See https://github.com/alicevision/AliceVision/blob/68ab70bcbc3eb01b73dc8dea78c78d8b4778461c/src/software/utils/main_colorCheckerDetection.cpp#L47\n    readonly property real ccheckerSizeX: 1675.0\n    readonly property real ccheckerSizeY: 1125.0\n\n    property var ccheckers: []\n    property int selectedCChecker: -1\n\n    Component.onCompleted: { readSourceFile() }\n    onSourceChanged: { readSourceFile() }\n    onViewpointChanged: { loadCCheckers() }\n    property var updatePane: null\n\n    function getColors() {\n        if (ccheckers[selectedCChecker] === undefined)\n            return null\n\n        if (ccheckers[selectedCChecker].colors === undefined)\n            return null\n\n        return ccheckers[selectedCChecker].colors\n    }\n\n    function readSourceFile() {\n        var xhr = new XMLHttpRequest\n        xhr.open(\"GET\", root.source)\n\n        xhr.onreadystatechange = function() {\n            if (xhr.readyState === XMLHttpRequest.DONE && xhr.status == 200) {\n                try {\n                    root.json = null\n                    root.json = JSON.parse(xhr.responseText)\n                } catch(exc) {\n                    console.warn(\"Failed to parse ColorCheckerDetection JSON file: \" + source)\n                    return\n                }\n            }\n            loadCCheckers()\n        }\n        xhr.send()\n    }\n\n    function loadCCheckers() {\n        emptyCCheckers()\n        if (root.json === null) {\n            return\n        }\n\n        var currentImagePath = (root.viewpoint && root.viewpoint.attribute && root.viewpoint.attribute.childAttribute(\"path\"))\n                               ? root.viewpoint.attribute.childAttribute(\"path\").value : null\n        var viewId = (root.viewpoint && root.viewpoint.attribute && root.viewpoint.attribute.childAttribute(\"viewId\"))\n                     ? root.viewpoint.attribute.childAttribute(\"viewId\").value : null\n\n        for (var i = 0; i < root.json.checkers.length; i++) {\n            // Only load ccheckers for the current view\n            var checker = root.json.checkers[i]\n            if (checker.viewId === viewId ||\n                checker.imagePath === currentImagePath) {\n                var cpt = Qt.createComponent(\"ColorCheckerEntity.qml\")\n\n                var obj = cpt.createObject(root, {\n                    x: ccheckerSizeX / 2,\n                    y: ccheckerSizeY / 2,\n                    sizeX: root.ccheckerSizeX,\n                    sizeY: root.ccheckerSizeY,\n                    colors: root.json.checkers[i].colors\n                })\n                obj.applyTransform(root.json.checkers[i].transform)\n                ccheckers.push(obj)\n                selectedCChecker = ccheckers.length - 1\n                break\n            }\n        }\n        updatePane()\n    }\n\n    function emptyCCheckers() {\n        for (var i = 0; i < ccheckers.length; i++)\n            ccheckers[i].destroy()\n        ccheckers = []\n        selectedCChecker = -1\n    }\n}\n"
  },
  {
    "path": "meshroom/ui/qml/Viewer/FeaturesInfoOverlay.qml",
    "content": "import QtQuick\nimport QtQuick.Controls\nimport QtQuick.Layouts\n\nimport Controls 1.0\nimport MaterialIcons 2.2\nimport Utils 1.0\n\n/**\n * FeaturesInfoOverlay is an overlay that displays info and\n * provides controls over a FeaturesViewer component.\n */\n\nFloatingPane {\n    id: root\n\n    property int pluginStatus: Loader.Null\n    property Item featuresViewer: null\n    property var mfeatures: null\n    property var mtracks: null\n    property var msfmdata: null\n    property var featuresNodeName: \"\"\n    property var tracksNodeName: \"\"\n    property var sfmdataNodeName: \"\"\n\n    ColumnLayout {\n        // Header\n        RowLayout {\n            ColumnLayout {\n            // Node used to read features\n                Label {\n                    text: \"Features Provider: \" + featuresNodeName\n                    Layout.fillWidth: true\n                }\n                Label {\n                    text: \"Tracks Provider: \" + tracksNodeName\n                    Layout.fillWidth: true\n                }\n                Label {\n                    text: \"SfMData Provider: \" + sfmdataNodeName\n                    Layout.fillWidth: true\n                }\n            }\n            // Settings menu\n            Loader {\n                Layout.alignment: Qt.AlignTop\n                active: root.pluginStatus === Loader.Ready\n                sourceComponent: MaterialToolButton {\n                    text: MaterialIcons.settings\n                    font.pointSize: 10\n                    onClicked: settingsMenu.popup(width, 0)\n                    Menu {\n                        id: settingsMenu\n                        padding: 4\n                        implicitWidth: 350\n\n                        RowLayout {\n                            Label {\n                                text: \"Feature Scale Filter:\"\n                            }\n                            RangeSlider {\n                                id: featureScaleFilterRS\n                                ToolTip.text: \"Filters features according to their scale (or filters tracks according to their average feature scale).\"\n                                ToolTip.visible: hovered\n                                Layout.fillHeight: true\n                                Layout.alignment: Qt.AlignRight\n                                from: 0\n                                to: 1\n                                first.value: 0\n                                first.onMoved: { root.featuresViewer.featureMinScaleFilter = Math.pow(first.value,4) }\n                                second.value: 1\n                                second.onMoved: { root.featuresViewer.featureMaxScaleFilter = Math.pow(second.value,4) }\n                                stepSize: 0.01\n                            }\n                        }\n                        RowLayout {\n                            Label {\n                                text: \"Feature Display Mode:\"\n                            }\n                            ComboBox {\n                                id: featureDisplayModeCB\n                                flat: true\n                                ToolTip.text: \"Feature Display Mode:\\n\" +\n                                              \"* Points: Simple points.\\n\" +\n                                              \"* Square: Scaled filled squares.\\n\" +\n                                              \"* Oriented Square: Scaled and oriented squares.\"\n                                ToolTip.visible: hovered\n                                Layout.fillHeight: true\n                                Layout.alignment: Qt.AlignRight\n                                model: root.featuresViewer ? root.featuresViewer.featureDisplayModes : null\n                                currentIndex: root.featuresViewer ? root.featuresViewer.featureDisplayMode : 0\n                                onActivated: root.featuresViewer.featureDisplayMode = currentIndex\n                            }\n                        }\n                        RowLayout {\n                            Label {\n                                text: \"Track Display Mode:\"\n                            }\n                            ComboBox {\n                                id: trackDisplayModeCB\n                                flat: true\n                                ToolTip.text: \"Track Display Mode:\\n\" +\n                                              \"* Lines Only: Only track lines.\\n\" +\n                                              \"* Current Matches: Track lines with current matches/landmarks.\\n\" +\n                                              \"* All Matches: Track lines with all matches / landmarks.\"\n                                ToolTip.visible: hovered\n                                Layout.fillHeight: true\n                                Layout.alignment: Qt.AlignRight\n                                model: root.featuresViewer ? root.featuresViewer.trackDisplayModes : null\n                                currentIndex: root.featuresViewer ? root.featuresViewer.trackDisplayMode : 0\n                                onActivated: root.featuresViewer.trackDisplayMode = currentIndex\n                            }\n                        }\n                        RowLayout {\n                            Label {\n                                text: \"Track Contiguous Filter:\"\n                            }\n                            CheckBox {\n                                id: trackContiguousFilterCB\n                                ToolTip.text: \"Hides non-contiguous track parts.\"\n                                ToolTip.visible: hovered\n                                Layout.fillHeight: true\n                                Layout.alignment: Qt.AlignRight\n                                checked: root.featuresViewer ? root.featuresViewer.trackContiguousFilter : false\n                                onClicked: root.featuresViewer.trackContiguousFilter = trackContiguousFilterCB.checked\n                            }\n                        }\n                        RowLayout {\n                            Label {\n                                text: \"Track Inliers Filter:\"\n                            }\n                            CheckBox {\n                                id: trackInliersFilterCB\n                                ToolTip.text: \"Hides tracks without at least one inlier.\"\n                                ToolTip.visible: hovered\n                                Layout.fillHeight: true\n                                Layout.alignment: Qt.AlignRight\n                                checked: root.featuresViewer ? root.featuresViewer.trackInliersFilter : false\n                                onClicked: root.featuresViewer.trackInliersFilter = trackInliersFilterCB.checked\n                            }\n                        }\n                        RowLayout {\n                            Label {\n                                text: \"Display 3D Tracks:\"\n                            }\n                            CheckBox {\n                                id: display3dTracksCB\n                                ToolTip.text: \"Draws tracks between 3d points instead of 2d points (if possible).\"\n                                ToolTip.visible: hovered\n                                Layout.fillHeight: true\n                                Layout.alignment: Qt.AlignRight\n                                checked: root.featuresViewer ? root.featuresViewer.display3dTracks : false\n                                onClicked: root.featuresViewer.display3dTracks = display3dTracksCB.checked\n                            }\n                        }\n                        RowLayout {\n                            Label {\n                                text: \"Display Track Endpoints:\"\n                            }\n                            CheckBox {\n                                id: displayTrackEndpointsCB\n                                ToolTip.text: \"Draws markers indicating the global start/end point of each track.\"\n                                ToolTip.visible: hovered\n                                Layout.fillHeight: true\n                                Layout.alignment: Qt.AlignRight\n                                checked: root.featuresViewer ? root.featuresViewer.displayTrackEndpoints : false\n                                onClicked: root.featuresViewer.displayTrackEndpoints = displayTrackEndpointsCB.checked\n                            }\n                        }\n                        RowLayout {\n                            Label {\n                                text: \"Time Window:\"\n                            }\n                            SpinBox {\n                                id: timeWindowSB\n                                ToolTip.text: \"Time Window: The number of frames to consider for tracks display.\\n\" +\n                                              \"e.g. With time window set at x, tracks will start at current frame - x and they will end at  current frame + x.\"\n                                ToolTip.visible: hovered\n                                Layout.fillHeight: true\n                                Layout.alignment: Qt.AlignRight\n                                from: -1\n                                to: 50\n                                value: root.featuresViewer ? root.featuresViewer.timeWindow : 0\n                                stepSize: 1\n                                editable: true\n\n                                textFromValue: function(value, locale) {\n                                    if (value === -1) return \"No Limit\"\n                                    if (value ===  0) return \"Disable\"\n                                    return value\n                                }\n\n                                valueFromText: function(text, locale) {\n                                    if (text === \"No Limit\") return -1\n                                    if (text === \"Disable\")  return 0\n                                    return Number.fromLocaleString(locale, text)\n                                }\n\n                                onValueChanged: {\n                                    if (root.featuresViewer)\n                                        root.featuresViewer.timeWindow = timeWindowSB.value\n                                }\n                            }\n                        }\n                    }\n                }\n            }\n        }\n\n        // Error message if AliceVision plugin is unavailable\n        Label {\n            visible: root.pluginStatus === Loader.Error\n            text: \"AliceVision plugin is required to display features\"\n            color: Colors.red\n        }\n\n        // Feature types\n        ListView {\n            implicitHeight: contentHeight\n            implicitWidth: contentItem.childrenRect.width\n\n            model: root.featuresViewer !== null ? root.featuresViewer.model : 0\n\n            delegate: RowLayout {\n                id: featureType\n                property var viewer: root.featuresViewer.itemAt(index)\n                spacing: 4\n\n                // Features visibility toggle\n                MaterialToolButton {\n                    id: featuresVisibilityButton\n                    checkable: true\n                    checked: true\n                    text: MaterialIcons.center_focus_strong\n                    ToolTip.text: \"Display Extracted Features\"\n                    onClicked: {\n                        featureType.viewer.displayFeatures = featuresVisibilityButton.checked\n                    }\n                    font.pointSize: 10\n                    opacity: featureType.viewer.visible ? 1.0 : 0.6\n                }\n                // Tracks visibility toggle\n                MaterialToolButton {\n                    id: tracksVisibilityButton\n                    checkable: true\n                    checked: false\n                    text: MaterialIcons.timeline\n                    ToolTip.text: \"Display Tracks\"\n                    onClicked: {\n                        featureType.viewer.displayTracks = tracksVisibilityButton.checked\n                        root.featuresViewer.enableTimeWindow = tracksVisibilityButton.checked\n                    }\n                    font.pointSize: 10\n                }\n                // Matches visibility toggle\n                MaterialToolButton {\n                    id: matchesVisibilityButton\n                    checkable: true\n                    checked: true\n                    text: MaterialIcons.sync\n                    ToolTip.text: \"Display Matches\"\n                    onClicked: {\n                        featureType.viewer.displayMatches = matchesVisibilityButton.checked\n                    }\n                    font.pointSize: 10\n                }\n                // Landmarks visibility toggle\n                MaterialToolButton {\n                    id: landmarksVisibilityButton\n                    checkable: true\n                    checked: true\n                    text: MaterialIcons.fiber_manual_record\n                    ToolTip.text: \"Display Landmarks\"\n                    onClicked: {\n                        featureType.viewer.displayLandmarks = landmarksVisibilityButton.checked\n                    }\n                    font.pointSize: 10\n                }\n                // ColorChart picker\n                ColorChart {\n                    implicitWidth: 12\n                    implicitHeight: implicitWidth\n                    colors: root.featuresViewer.colors\n                    currentIndex: featureType.viewer.colorIndex\n                    // offset featuresViewer color set when changing the color of one feature type\n                    onColorPicked: function(colorIndex) {\n                        featureType.viewer.colorOffset = colorIndex - index\n                    }\n                }\n                // Feature type name\n                Label {\n                    property string descType: featureType.viewer.describerType\n                    property int viewId: root.featuresViewer.currentViewId\n                    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) : \" - \")\n                }\n                // Feature loading status\n                Loader {\n                    active: (root.mfeatures && root.mfeatures.status === MFeatures.Loading)\n                    sourceComponent: BusyIndicator {\n                        padding: 0\n                        implicitWidth: 12\n                        implicitHeight: 12\n                        running: true\n                    }\n                }\n            }\n        }\n    }\n}\n"
  },
  {
    "path": "meshroom/ui/qml/Viewer/FeaturesViewer.qml",
    "content": "import QtQuick\n\nimport AliceVision 1.0 as AliceVision\nimport Utils 1.0\n\n/**\n * FeaturesViewer displays the extracted feature points of a View.\n * Requires QtAliceVision plugin.\n */\n\nRepeater {\n    id: root\n\n    /// Features\n    property var features\n    /// Tracks\n    property var tracks\n    /// SfMData\n    property var sfmData\n\n    /// The list of describer types to load\n    property alias describerTypes: root.model\n\n    /// List of available feature display modes\n    readonly property var featureDisplayModes: ['Points', 'Squares', 'Oriented Squares']\n    /// Current feature display mode index\n    property int featureDisplayMode: 2\n\n    /// List of available track display modes\n    readonly property var trackDisplayModes: ['Lines Only', 'Current Matches', 'All Matches']\n    /// Current track display mode index\n    property int trackDisplayMode: 1\n\n    // Minimum feature scale score to display\n    property real featureMinScaleFilter: 0\n    // Maximum feature scale score to display\n    property real featureMaxScaleFilter: 1\n\n    /// Display 3d tracks\n    property bool display3dTracks: false\n\n    /// Display only contiguous tracks\n    property bool trackContiguousFilter: true\n\n    /// Display only tracks with at least one inlier\n    property bool trackInliersFilter: false\n\n    /// Display track endpoints\n    property bool displayTrackEndpoints: true\n\n    /// The list of colors used for displaying several describers\n    property var colors: [Colors.blue, Colors.green, Colors.yellow, Colors.cyan, Colors.pink, Colors.lime] //, Colors.orange, Colors.red\n\n    /// Current view ID\n    property var currentViewId\n    property bool syncFeaturesSelected: false\n\n    /// Time window\n    property bool enableTimeWindow: false\n    property int timeWindow: 1\n\n    model: root.describerTypes\n\n    // Instantiate one FeaturesViewer by describer type\n    delegate: AliceVision.FeaturesViewer {\n        readonly property int colorIndex: (index + colorOffset) % root.colors.length\n        property int colorOffset: 0\n        featureDisplayMode: root.featureDisplayMode\n        trackDisplayMode: root.trackDisplayMode\n        featureMinScaleFilter: root.featureMinScaleFilter\n        featureMaxScaleFilter: root.featureMaxScaleFilter\n        display3dTracks: root.display3dTracks\n        trackContiguousFilter: root.trackContiguousFilter\n        trackInliersFilter: root.trackInliersFilter\n        displayTrackEndpoints: root.displayTrackEndpoints\n        featureColor: root.colors[colorIndex]\n        matchColor: Colors.orange\n        landmarkColor: Colors.red\n        describerType: modelData\n        currentViewId: syncFeaturesSelected ? _currentScene.pickedViewId : root.currentViewId\n        enableTimeWindow: root.enableTimeWindow\n        timeWindow: root.timeWindow\n        mfeatures: root.features\n        mtracks: root.tracks\n        msfmData: root.sfmData\n    }\n}\n"
  },
  {
    "path": "meshroom/ui/qml/Viewer/FloatImage.qml",
    "content": "import QtQuick\n\nimport AliceVision 1.0 as AliceVision\nimport Utils 1.0\n\n/**\n * FloatImage displays an Image with gamma / offset / channel controls\n * Requires QtAliceVision plugin.\n */\n\nAliceVision.FloatImageViewer {\n    id: root\n\n    width: sourceSize.width\n    height: sourceSize.height\n    visible: true\n\n    // paintedWidth / paintedHeight / imageStatus for compatibility with standard Image\n    property int paintedWidth: sourceSize.width\n    property int paintedHeight: sourceSize.height\n    property var imageStatus: {\n        if (root.status === AliceVision.FloatImageViewer.EStatus.LOADING) {\n            return Image.Loading\n        } else if (root.status === AliceVision.FloatImageViewer.EStatus.LOADING_ERROR ||\n                   root.status === AliceVision.FloatImageViewer.EStatus.MISSING_FILE ||\n                   root.status === AliceVision.FloatImageViewer.EStatus.OUTDATED_LOADING) {\n            return Image.Error\n        } else if ((root.source === \"\") || (root.sourceSize.height <= 0) || (root.sourceSize.width <= 0)) {\n            return Image.Null\n        }\n\n        return Image.Ready\n    }\n\n    onStatusChanged: {\n        if (viewerTypeString === \"panorama\") {\n            var activeNode = _currentScene.activeNodes.get('SfMTransform').node\n        }\n        root.surface.setIdView(idView);\n    }\n\n    property string channelModeString : \"rgba\"\n    channelMode: {\n        switch (channelModeString) {\n            case \"rgb\": return AliceVision.FloatImageViewer.EChannelMode.RGB\n            case \"r\": return AliceVision.FloatImageViewer.EChannelMode.R\n            case \"g\": return AliceVision.FloatImageViewer.EChannelMode.G\n            case \"b\": return AliceVision.FloatImageViewer.EChannelMode.B\n            case \"a\": return AliceVision.FloatImageViewer.EChannelMode.A\n            default: return AliceVision.FloatImageViewer.EChannelMode.RGBA\n        }\n    }\n\n    property string viewerTypeString : \"hdr\"\n    surface.viewerType: {\n        switch (viewerTypeString) {\n            case \"hdr\": return AliceVision.Surface.EViewerType.HDR;\n            case \"distortion\": return AliceVision.Surface.EViewerType.DISTORTION;\n            case \"panorama\": return AliceVision.Surface.EViewerType.PANORAMA;\n            default: return AliceVision.Surface.EViewerType.HDR;\n        }\n    }\n\n    property int pointsNumber: (surface.subdivisions + 1) * (surface.subdivisions + 1)\n\n    property int idView: 0;\n\n    clearBeforeLoad: false\n\n    property alias containsMouse: mouseArea.containsMouse\n    property alias mouseX: mouseArea.mouseX\n    property alias mouseY: mouseArea.mouseY\n    MouseArea {\n        id: mouseArea\n        anchors.fill: parent\n        hoverEnabled: true\n        // Do not intercept mouse events, only get the mouse over information\n        acceptedButtons: Qt.NoButton\n    }\n\n    function isMouseOver(mx, my) {\n        return root.surface.isMouseInside(mx, my)\n    }\n\n    function getMouseCoordinates(mx, my) {\n        if (isMouseOver(mx, my)) {\n            root.surface.mouseOver = true\n            return true\n        } else {\n            root.surface.mouseOver = false\n            return false\n        }\n    }\n\n    function onChangedHighlightState(isHighlightable) {\n        if (!isHighlightable) root.surface.mouseOver = false\n    }\n\n    /*\n    * Principal Point\n    */\n\n    function updatePrincipalPoint() {\n        var pp = root.surface.getPrincipalPoint()\n        ppRect.x = pp.x\n        ppRect.y = pp.y\n    }\n\n    property bool isPrincipalPointsDisplayed : false\n\n    Item {\n        id: principalPoint\n        Rectangle {\n            id: ppRect\n            width: root.sourceSize.width/150; height: width\n            radius : width/2\n            x: 0\n            y: 0\n            color: \"red\"\n            visible: viewerTypeString === \"distortion\" && isPrincipalPointsDisplayed\n            onVisibleChanged: {\n                updatePrincipalPoint()\n            }\n        }\n    }\n}\n"
  },
  {
    "path": "meshroom/ui/qml/Viewer/HdrImageToolbar.qml",
    "content": "import QtQuick\nimport QtQuick.Controls\nimport QtQuick.Layouts\n\nimport Controls 1.0\nimport Utils 1.0\n\nFloatingPane {\n    id: root\n    anchors.margins: 0\n    padding: 5\n    radius: 0\n\n    property real gainDefaultValue: 1.0\n    property real gammaDefaultValue: 1.0\n    property string pixelCoordinatesPlaceholder: \"--\"\n\n\n    property real slidersPowerValue: 4.0\n    property real gainValue: Math.pow(gainCtrl.value, slidersPowerValue).toFixed(2)\n    property real gammaValue: Math.pow(gammaCtrl.value, slidersPowerValue).toFixed(2)\n    property alias channelModeValue: channelsCtrl.value\n    property variant colorRGBA: null\n    property variant mousePosition: ({x:0, y:0})\n\n    property bool colorPickerVisible: true\n\n    property variant userDefinedXPixel: null\n    property variant userDefinedYPixel: null\n\n    background: Rectangle { color: root.palette.window }\n\n    function resetDefaultValues() {\n        gainCtrl.value = root.gainDefaultValue\n        gammaCtrl.value = root.gammaDefaultValue\n    }\n\n    function resetPixelCoordinates() {\n        if(userDefinedXPixel !== null) { userDefinedXPixel = null }\n        if(userDefinedYPixel !== null) { userDefinedYPixel = null }        \n    }\n\n    function toggleChannel(channelName, defaultChannel) {\n        /* \n            toggle channelBox to the given channelName.\n            If the channel is already set, the defaultChannel is set\n\n         */\n        if (!setChannel(channelName)) {\n            setChannel(defaultChannel)\n        }\n    }\n\n    function setChannel(channelName) {\n        /* \n            set the given channel in the combobox\n         */\n        if (channelName === channelsCtrl.value) {\n            return false\n        }\n\n        const channelIndex = channelsCtrl.channels.indexOf(channelName)\n        if (channelIndex === -1 ) { \n            return false \n        }\n\n        channelsCtrl.currentIndex = channelIndex\n        return true\n    }\n\n    onMousePositionChanged: {\n        resetPixelCoordinates()\n    }\n\n    DoubleValidator {\n        id: doubleValidator\n        locale: 'C'  // Use '.' decimal separator disregarding of the system locale\n    }\n\n    RowLayout {\n        id: toolLayout\n        anchors.fill: parent\n\n        // Channel mode\n        ComboBox {\n            id: channelsCtrl\n\n            // Set min size to 4 characters + one margin for the combobox\n            Layout.minimumWidth: 5.0 * Qt.application.font.pixelSize\n            Layout.preferredWidth: Layout.minimumWidth\n            flat: true\n\n            property var channels: [\"rgba\", \"rgb\", \"r\", \"g\", \"b\",\"a\"]\n            property string value: channels[currentIndex]\n\n            onValueChanged: {\n                currentIndex = channels.indexOf(value)\n            }\n\n            model: channels\n        }\n\n        // Gain slider\n        RowLayout {\n            spacing: 5\n\n            ToolButton {\n                text: \"Gain\"\n\n                ToolTip.visible: ToolTip.text && hovered\n                ToolTip.delay: 100\n                ToolTip.text: \"Reset Gain\"\n\n                onClicked: {\n                    gainLabel.text = gainDefaultValue\n                    gainCtrl.value = gainLabel.text\n                }\n            }\n            ExpressionTextField {\n                id: gainLabel\n\n                ToolTip.visible: ToolTip.text && hovered\n                ToolTip.delay: 100\n                ToolTip.text: \"Color Gain (in linear colorspace)\"\n\n                text: gainValue\n                Layout.preferredWidth: textMetrics_gainValue.width\n                selectByMouse: true\n                onAccepted: {\n                    if (!gainLabel.hasExprError) {\n                        if (gainLabel.text <= 0) {\n                            gainLabel.evaluatedValue = 0\n                            gainCtrl.value = gainLabel.evaluatedValue\n                        } else {\n                            gainCtrl.value = Math.pow(Number(gainLabel.evaluatedValue), 1.0 / slidersPowerValue)\n                        }\n                    }\n                }\n            }\n            Slider {\n                id: gainCtrl\n                Layout.fillWidth: true\n                from: 0.01\n                to: 2\n                value: gainDefaultValue\n                stepSize: 0.01\n                onMoved: {\n                    gainLabel.text = Math.pow(value, slidersPowerValue).toFixed(2)\n                }\n            }\n        }\n\n        // Gamma slider\n        RowLayout {\n            spacing: 5\n\n            ToolButton {\n                text: \"γ\"\n\n                ToolTip.visible: ToolTip.text && hovered\n                ToolTip.delay: 100\n                ToolTip.text: \"Reset Gamma\"\n\n                onClicked: {\n                    gammaLabel.text = gammaDefaultValue\n                    gammaCtrl.value = gammaLabel.text\n                }\n            }\n            ExpressionTextField {\n                id: gammaLabel\n\n                ToolTip.visible: ToolTip.text && hovered\n                ToolTip.delay: 100\n                ToolTip.text: \"Apply Gamma (after Gain and in linear colorspace)\"\n\n                text: gammaValue\n                Layout.preferredWidth: textMetrics_gainValue.width\n                selectByMouse: true\n                onAccepted: {\n                    if (!gammaLabel.hasExprError) {\n                        if (gammaLabel.evaluatedValue <= 0) {\n                            gammaLabel.evaluatedValue = 0\n                            gammaCtrl.value = gammaLabel.evaluatedValue\n                        } else {\n                            gammaCtrl.value = Math.pow(Number(gammaLabel.evaluatedValue), 1.0 / slidersPowerValue)\n                        }\n                    }\n                }\n            }\n            Slider {\n                id: gammaCtrl\n                Layout.fillWidth: true\n                from: 0.01\n                to: 2\n                value: gammaDefaultValue\n                stepSize: 0.01\n                onMoved: {\n                    gammaLabel.text = Math.pow(value, slidersPowerValue).toFixed(2)\n                }\n            }\n        }\n\n        RowLayout {\n\n            Label {\n                text: \"x\"\n            }\n            TextField {\n                id: xPixel\n                text: root.mousePosition ? root.mousePosition.x : null\n                Layout.preferredWidth: 40\n                placeholderText: pixelCoordinatesPlaceholder\n                validator: IntValidator { bottom: 0 }\n                onTextEdited: {\n                    const xPixelValue = parseInt(xPixel.text)\n                    userDefinedXPixel = Number.isNaN(xPixelValue) ? null : xPixelValue\n                }\n            }\n            Label {\n                text: \"y\"\n            }\n            TextField {\n                id: yPixel\n                text: root.mousePosition ? root.mousePosition.y : null\n                Layout.preferredWidth: 40\n                placeholderText: pixelCoordinatesPlaceholder\n                validator: IntValidator { bottom: 0 }\n                onTextEdited: {\n                    const yPixelValue = parseInt(yPixel.text)\n                    userDefinedYPixel = Number.isNaN(yPixelValue) ? null : yPixelValue\n                }\n            }\n\n        }\n\n        Rectangle {\n            visible: colorPickerVisible\n            Layout.preferredWidth: 20\n            implicitWidth: 20\n            implicitHeight: parent.height\n            color: root.colorRGBA ? Qt.rgba(red.value_gamma, green.value_gamma, blue.value_gamma, 1.0) : \"black\"\n        }\n\n        // RGBA colors\n        RowLayout {\n            spacing: 1\n            visible: colorPickerVisible\n\n            TextField {\n                id: red\n                property real value: root.colorRGBA ? root.colorRGBA.x : 0.0\n                property real value_gamma: Math.pow(value, 1.0 / 2.2)\n                text: root.colorRGBA ? value.toFixed(6) : \"--\"\n\n                Layout.preferredWidth: textMetrics_colorValue.width\n                selectByMouse: true\n                validator: doubleValidator\n                horizontalAlignment: TextInput.AlignLeft\n                readOnly: true\n                // autoScroll: When the text is too long, display the left part\n                // (with the most important values and cut the floating point details)\n                autoScroll: false\n\n                Rectangle {\n                    anchors.verticalCenter: parent.bottom\n                    width: parent.width\n                    height: 3\n                    color: Qt.rgba(red.value_gamma, 0.0, 0.0, 1.0)\n                }\n            }\n            TextField {\n                id: green\n                property real value: root.colorRGBA ? root.colorRGBA.y : 0.0\n                property real value_gamma: Math.pow(value, 1.0/2.2)\n                text: root.colorRGBA ? value.toFixed(6) : \"--\"\n                \n                Layout.preferredWidth: textMetrics_colorValue.width\n                selectByMouse: true\n                validator: doubleValidator\n                horizontalAlignment: TextInput.AlignLeft\n                readOnly: true\n                // autoScroll: When the text is too long, display the left part\n                // (with the most important values and cut the floating point details)\n                autoScroll: false\n\n                Rectangle {\n                    anchors.verticalCenter: parent.bottom\n                    width: parent.width\n                    height: 3\n                    color: Qt.rgba(0.0, green.value_gamma, 0.0, 1.0)\n                }\n            }\n            TextField {\n                id: blue\n                property real value: root.colorRGBA ? root.colorRGBA.z : 0.0\n                property real value_gamma: Math.pow(value, 1.0 / 2.2)\n                text: root.colorRGBA ? value.toFixed(6) : \"--\"\n                \n                Layout.preferredWidth: textMetrics_colorValue.width\n                selectByMouse: true\n                validator: doubleValidator\n                horizontalAlignment: TextInput.AlignLeft\n                readOnly: true\n                // autoScroll: When the text is too long, display the left part\n                // (with the most important values and cut the floating point details)\n                autoScroll: false\n\n                Rectangle {\n                    anchors.verticalCenter: parent.bottom\n                    width: parent.width\n                    height: 3\n                    color: Qt.rgba(0.0, 0.0, blue.value_gamma, 1.0)\n                }\n            }\n            TextField {\n                id: alpha\n                property real value: root.colorRGBA ? root.colorRGBA.w : 0.0\n                property real value_gamma: Math.pow(value, 1.0 / 2.2)\n                text: root.colorRGBA ? value.toFixed(6) : \"--\"\n                \n                Layout.preferredWidth: textMetrics_colorValue.width\n                selectByMouse: true\n                validator: doubleValidator\n                horizontalAlignment: TextInput.AlignLeft\n                readOnly: true\n                // autoScroll: When the text is too long, display the left part\n                // (with the most important values and cut the floating point details)\n                autoScroll: false\n\n                Rectangle {\n                    anchors.verticalCenter: parent.bottom\n                    width: parent.width\n                    height: 3\n                    color: Qt.rgba(alpha.value_gamma, alpha.value_gamma, alpha.value_gamma, 1.0)\n                }\n            }\n        }\n    }\n    TextMetrics {\n        id: textMetrics_colorValue\n        font: red.font\n        text: \"1.2345\"  // Use one more than expected to get the correct value (probably needed due to TextField margin)\n    }\n    TextMetrics {\n        id: textMetrics_gainValue\n        font: gainLabel.font\n        text: \"1.2345\"\n    }\n}\n"
  },
  {
    "path": "meshroom/ui/qml/Viewer/ImageMetadataView.qml",
    "content": "import QtQuick\nimport QtQuick.Controls\nimport QtQuick.Layouts\n\nimport QtPositioning 6.6\nimport QtLocation 6.6\n\nimport Controls 1.0\nimport MaterialIcons 2.2\nimport Utils 1.0\n\n/**\n * ImageMetadataView displays a JSON model representing an image's metadata as a ListView.\n */\n\nFloatingPane {\n    id: root\n\n    property alias metadata: metadataModel.metadata\n    property var coordinates: QtPositioning.coordinate()\n\n    clip: true\n    padding: 4\n    anchors.rightMargin: 0\n\n    /**\n     * Convert GPS metadata to degree coordinates.\n     *\n     * GPS coordinates in metadata can be store in 3 forms:\n     * (degrees), (degrees, minutes), (degrees, minutes, seconds)\n     */\n    function gpsMetadataToCoordinates(value, ref) {\n        var values = value.split(\",\")\n        var result = 0\n        for (var i = 0; i < values.length; ++i) {\n            // Divide each component by the corresponding power of 60\n            // 1 for degree, 60 for minutes, 3600 for seconds\n            result += Number(values[i]) / Math.pow(60, i)\n        }\n        // Handle opposite reference: South (latitude) or West (longitude)\n        return (ref === \"S\" || ref === \"W\") ? -result : result\n    }\n\n    /// Try to get GPS coordinates from metadata\n    function getGPSCoordinates(metadata) {\n        // GPS data available\n        if (metadata && metadata[\"GPS:Longitude\"] !== undefined && metadata[\"GPS:Latitude\"] !== undefined) {\n            var latitude = gpsMetadataToCoordinates(metadata[\"GPS:Latitude\"], metadata[\"GPS:LatitudeRef\"])\n            var longitude = gpsMetadataToCoordinates(metadata[\"GPS:Longitude\"], metadata[\"GPS:LongitudeRef\"])\n            var altitude = metadata[\"GPS:Altitude\"] || 0\n            return QtPositioning.coordinate(latitude, longitude, altitude)\n        } else {  // GPS data unavailable: reset coordinates to default value\n            return QtPositioning.coordinate()\n        }\n    }\n\n    // Metadata model\n    // Available roles for child items:\n    //   - group: metadata group if any, \"-\" otherwise\n    //   - key: metadata key\n    //   - value: metadata value\n    //   - raw: a sortable/filterable representation of the metadata as \"group:key=value\"\n    ListModel {\n        id: metadataModel\n        property var metadata: ({})\n\n        // Reset model when metadata changes\n        onMetadataChanged: {\n            metadataModel.clear()\n            var entries = []\n            // Prepare data to populate the model from the input metadata object\n            for (var key in metadata) {\n                var entry = {}\n                // Split on \":\" to get group and key\n                var i = key.lastIndexOf(\":\")\n                if (i === -1) {\n                    i = key.lastIndexOf(\"/\")\n                }\n\n                if (i !== -1) {\n                    entry[\"group\"] = key.substr(0, i)\n                    entry[\"key\"] = key.substr(i+1)\n                } else {\n                    // Set default group to something convenient for sorting\n                    entry[\"group\"] = \"-\"\n                    entry[\"key\"] = key\n                }\n\n                // If a key has an empty corresponding value, set it as an empty string.\n                // Otherwise it will be considered as a Variant Map.\n                if (typeof(metadata[key]) != \"string\")\n                    entry[\"value\"] = \"\"\n                else\n                    entry[\"value\"] = metadata[key]\n                entry[\"raw\"] = entry[\"group\"] + \":\" + entry[\"key\"] + \"=\" + entry[\"value\"]\n                entries.push(entry)\n            }\n            // Reset the model with prepared data (limit to one update event)\n            metadataModel.append(entries)\n            coordinates = getGPSCoordinates(metadata)\n        }\n    }\n\n    // Background WheelEvent grabber\n    MouseArea {\n        anchors.fill: parent\n        acceptedButtons: Qt.MiddleButton\n        onWheel: function(wheel) { wheel.accepted = true }\n    }\n\n    // Main Layout\n    ColumnLayout {\n        anchors.fill: parent\n\n        SearchBar {\n            id: searchBar\n            Layout.fillWidth: true\n        }\n        RowLayout {\n            Layout.alignment: Qt.AlignHCenter\n            Label {\n                font.family: MaterialIcons.fontFamily\n                text: MaterialIcons.shutter_speed\n            }\n            Label {\n                id: exposureLabel\n                text: {\n                    if (metadata[\"ExposureTime\"] === undefined)\n                        return \"\"\n                    var expStr = metadata[\"ExposureTime\"]\n                    var exp = parseFloat(expStr)\n                    if (exp < 1.0) {\n                        var invExp = 1.0 / exp\n                        return \"1/\" + invExp.toFixed(0)\n                    }\n                    return expStr\n                }\n                elide: Text.ElideRight\n                horizontalAlignment: Text.AlignHLeft\n            }\n            Item { width: 4 }\n            Label {\n                font.family: MaterialIcons.fontFamily\n                text: MaterialIcons.camera\n            }\n            Label {\n                id: fnumberLabel\n                text: (metadata[\"FNumber\"] !== undefined) ? (\"f/\" + metadata[\"FNumber\"]) : \"\"\n                elide: Text.ElideRight\n                horizontalAlignment: Text.AlignHLeft\n            }\n            Item { width: 4 }\n            Label {\n                font.family: MaterialIcons.fontFamily\n                text: MaterialIcons.iso\n            }\n            Label {\n                id: isoLabel\n                text: metadata[\"Exif:ISOSpeedRatings\"] || \"\"\n                elide: Text.ElideRight\n                horizontalAlignment: Text.AlignHLeft\n            }\n        }\n        // Metadata ListView\n        ListView {\n            id: metadataView\n            Layout.fillWidth: true\n            Layout.fillHeight: true\n            spacing: 3\n            clip: true\n\n            // SortFilter delegate over the metadataModel\n            model: SortFilterDelegateModel {\n                id: sortedMetadataModel\n                model: metadataModel\n                sortRole: \"raw\"\n                filters: [{role: \"raw\", value: searchBar.text}]\n                delegate: RowLayout {\n                    width: ListView.view.width\n                    Label {\n                        text: key\n                        leftPadding: 6\n                        rightPadding: 4\n                        Layout.preferredWidth: sizeHandle.x\n                        elide: Text.ElideRight\n                    }\n                    Label {\n                        text: value != undefined ? value : \"\"\n                        Layout.fillWidth: true\n                        wrapMode: Label.WrapAtWordBoundaryOrAnywhere\n                    }\n                }\n            }\n\n            // Categories resize handle\n            Rectangle {\n                id: sizeHandle\n                height: parent.contentHeight\n                width: 1\n                color: root.palette.mid\n                x: parent.width * 0.4\n                MouseArea {\n                    anchors.fill: parent\n                    anchors.margins: -4\n                    cursorShape: Qt.SizeHorCursor\n                    drag {\n                        target: parent\n                        axis: Drag.XAxis\n                        threshold: 0\n                        minimumX: metadataView.width * 0.2\n                        maximumX: metadataView.width * 0.8\n                    }\n                }\n            }\n            // Display section based on metadata group\n            section.property: \"group\"\n            section.delegate: Pane {\n                width: parent.width\n                padding: 3\n                background: null\n\n                Label {\n                    width: parent.width\n                    padding: 2\n                    background: Rectangle { color: parent.palette.mid }\n                    text: section\n                }\n            }\n            ScrollBar.vertical: ScrollBar{}\n        }\n\n\n        // Display map if GPS coordinates are available\n        Loader {\n            Layout.fillWidth: true\n            Layout.preferredHeight: coordinates.isValid ? 160 : 0\n\n            active: coordinates.isValid\n\n            Plugin {\n                id: osmPlugin\n                name: \"osm\"\n            }\n\n            sourceComponent: Map {\n                id: map\n                plugin: osmPlugin\n                center: coordinates\n\n                function recenter() {\n                    center = coordinates\n                }\n\n                Connections {\n                    target: root\n                    function onCoordinatesChanged() { recenter() }\n                }\n\n                zoomLevel: 16\n                // Coordinates visual indicator\n                MapQuickItem {\n                    coordinate: coordinates\n                    anchorPoint.x: circle.paintedWidth / 2\n                    anchorPoint.y: circle.paintedHeight\n                    sourceItem: Text {\n                        id: circle\n                        color: root.palette.highlight\n                        font.pointSize: 18\n                        font.family: MaterialIcons.fontFamily\n                        text: MaterialIcons.location_on\n                    }\n                }\n                // Reset map center\n                FloatingPane {\n                    anchors.right: parent.right\n                    anchors.top: parent.top\n                    padding: 2\n                    visible: map.center !== coordinates\n\n                    ToolButton {\n                        font.family: MaterialIcons.fontFamily\n                        text: MaterialIcons.my_location\n                        ToolTip.visible: hovered\n                        ToolTip.text: \"Recenter\"\n                        padding: 0\n                        onClicked: recenter()\n                    }\n                }\n            }\n        }\n    }\n}\n"
  },
  {
    "path": "meshroom/ui/qml/Viewer/LensDistortionToolbar.qml",
    "content": "import QtQuick\nimport QtQuick.Controls\nimport QtQuick.Layouts\n\nimport Controls 1.0\nimport MaterialIcons 2.2\nimport Utils 1.0\n\nFloatingPane {\n    id: root\n    anchors.margins: 0\n    padding: 5\n    radius: 0\n\n    property int opacityDefaultValue: 70\n    property int subdivisionsDefaultValue: 12\n\n    property int opacityValue: Math.pow(opacityCtrl.value, 1)\n    property int subdivisionsValue: subdivisionsCtrl.value\n\n    property variant colorRGBA: null\n    property bool displayGrid: displayGridButton.checked\n    property bool displayPrincipalPoint: displayPrincipalPointButton.checked\n\n    property var colors: [Colors.lightgrey, Colors.grey, Colors.red, Colors.green, Colors.blue, Colors.yellow]\n    readonly property int colorIndex: (colorOffset) % root.colors.length\n    property int colorOffset: 0\n    property color color: root.colors[gridColorPicker.currentIndex]\n\n    background: Rectangle { color: root.palette.window }\n\n    DoubleValidator {\n        id: doubleValidator\n        locale: 'C'  // Use '.' decimal separator disregarding of the system locale\n    }\n\n    RowLayout {\n        id: toolLayout\n        anchors.fill: parent\n\n        MaterialToolButton {\n            id: displayPrincipalPointButton\n            ToolTip.text: \"Display Principal Point\"\n            text: MaterialIcons.control_point\n            font.pointSize: 13\n            padding: 5\n            Layout.minimumWidth: 0\n            checkable: true\n            checked: false\n        }\n        MaterialToolButton {\n            id: displayGridButton\n            ToolTip.text: \"Display Grid\"\n            text: MaterialIcons.grid_on\n            font.pointSize: 13\n            padding: 5\n            Layout.minimumWidth: 0\n            checkable: true\n            checked: true\n        }\n        ColorChart {\n            id : gridColorPicker\n            padding : 10\n            colors: root.colors\n            currentIndex: root.colorIndex\n            onColorPicked: function(colorIndex) { root.colorOffset = colorIndex }\n        }\n\n        // Grid opacity slider\n        RowLayout {\n            spacing: 5\n\n            ToolButton {\n                text: \"Grid Opacity\"\n\n                ToolTip.visible: ToolTip.text && hovered\n                ToolTip.delay: 100\n                ToolTip.text: \"Reset Opacity\"\n\n                onClicked: {\n                    opacityCtrl.value = opacityDefaultValue\n                }\n            }\n            TextField {\n                id: opacityLabel\n\n                ToolTip.visible: ToolTip.text && hovered\n                ToolTip.delay: 100\n                ToolTip.text: \"Grid opacity\"\n\n                text: opacityValue.toFixed(1)\n                horizontalAlignment: \"AlignHCenter\"\n                Layout.preferredWidth: textMetrics_opacityValue.width\n                selectByMouse: true\n                validator: doubleValidator\n                onAccepted: {\n                    opacityCtrl.value = Number(opacityLabel.text)\n                }\n            }\n            Slider {\n                id: opacityCtrl\n                Layout.fillWidth: false\n                from: 0\n                to: 100\n                value: opacityDefaultValue\n                stepSize: 1\n            }\n        }\n\n        // Grid subdivisions slider\n        RowLayout {\n            spacing: 5\n\n            ToolButton {\n                text: \"Subdivisions\"\n\n                ToolTip.visible: ToolTip.text && hovered\n                ToolTip.delay: 100\n                ToolTip.text: \"Reset Subdivisions\"\n\n                onClicked: {\n                    subdivisionsCtrl.value = subdivisionsDefaultValue\n                }\n            }\n            TextField {\n                id: subdivisionsLabel\n\n                ToolTip.visible: ToolTip.text && hovered\n                ToolTip.delay: 100\n                ToolTip.text: \"subdivisions\"\n\n                text: subdivisionsValue.toFixed(1)\n                horizontalAlignment: \"AlignHCenter\"\n                Layout.preferredWidth: textMetrics_subdivisionsValue.width\n                selectByMouse: true\n                validator: doubleValidator\n                onAccepted: {\n                    subdivisionsCtrl.value = Number(subdivisionsLabel.text)\n                }\n            }\n            Slider {\n                id: subdivisionsCtrl\n                Layout.fillWidth: false\n                from: 2\n                to: 40\n                value: subdivisionsDefaultValue\n                stepSize: 5\n            }\n        }\n\n        // Fill rectangle to have a better UI\n        Rectangle {\n            color: root.palette.window\n            Layout.fillWidth: true\n        }\n    }\n\n    TextMetrics {\n        id: textMetrics_opacityValue\n        font: opacityLabel.font\n        text: \"100.000\"\n    }\n    TextMetrics {\n        id: textMetrics_subdivisionsValue\n        font: opacityLabel.font\n        text: \"100.00\"\n    }\n}\n"
  },
  {
    "path": "meshroom/ui/qml/Viewer/MFeatures.qml",
    "content": "import QtQuick\n\nimport AliceVision 1.0 as AliceVision\n\n// Data from the View / Features.\nAliceVision.MFeatures {\n    id: root\n}\n"
  },
  {
    "path": "meshroom/ui/qml/Viewer/MSfMData.qml",
    "content": "import QtQuick\n\nimport AliceVision 1.0 as AliceVision\n\n// Data from the SfM \nAliceVision.MSfMData {\n    id: root\n}\n"
  },
  {
    "path": "meshroom/ui/qml/Viewer/MTracks.qml",
    "content": "import QtQuick\n\nimport AliceVision 1.0 as AliceVision\n\nAliceVision.MTracks {\n    id: root\n}\n"
  },
  {
    "path": "meshroom/ui/qml/Viewer/PanoramaToolbar.qml",
    "content": "import QtQuick\nimport QtQuick.Controls\nimport QtQuick.Layouts\n\nimport Controls 1.0\nimport MaterialIcons 2.2\nimport Utils 1.0\n\nFloatingPane {\n    id: root\n    anchors.margins: 0\n    padding: 5\n    radius: 0\n\n    property bool enableEdit: enablePanoramaEdit.checked\n    property bool enableHover: enableHover.checked\n    property bool displayGrid: displayGrid.checked\n\n    property int downscaleValue: downscaleSpinBox.value\n    property int downscaleDefaultValue: 4\n\n    property int subdivisionsDefaultValue: 24\n    property int subdivisionsValue: subdivisionsCtrl.value\n\n    property int mouseSpeed: speedSpinBox.value\n\n    background: Rectangle { color: root.palette.window }\n\n    function updateDownscaleValue(level) {\n        downscaleSpinBox.value = level\n    }\n\n    DoubleValidator {\n        id: doubleValidator\n        locale: 'C'  // Use '.' decimal separator disregarding of the system locale\n    }\n\n    RowLayout {\n        id: toolLayout\n        anchors.fill: parent\n\n        MaterialToolButton {\n            id: enablePanoramaEdit\n            ToolTip.text: \"Enable Panorama edition\"\n            text: MaterialIcons.open_with\n            font.pointSize: 14\n            padding: 5\n            Layout.minimumWidth: 0\n            checkable: true\n            checked: true\n        }\n        MaterialToolButton {\n            id: enableHover\n            ToolTip.text: \"Enable hovering highlight\"\n            text: MaterialIcons.highlight\n            font.pointSize: 14\n            padding: 5\n            Layout.minimumWidth: 0\n            checkable: true\n            checked: true\n        }\n        MaterialToolButton {\n            id: displayGrid\n            ToolTip.text: \"Display grid\"\n            text: MaterialIcons.grid_on\n            font.pointSize: 14\n            padding: 5\n            Layout.minimumWidth: 0\n            checkable: true\n            checked: true\n        }\n        RowLayout {\n            spacing: 5\n\n            ToolButton {\n                text: \"Subdivisions\"\n\n                ToolTip.visible: ToolTip.text && hovered\n                ToolTip.delay: 100\n                ToolTip.text: \"Reset Subdivisions\"\n\n                onClicked: {\n                    subdivisionsCtrl.value = subdivisionsDefaultValue\n                }\n            }\n            TextField {\n                id: subdivisionsLabel\n\n                ToolTip.visible: ToolTip.text && hovered\n                ToolTip.delay: 100\n                ToolTip.text: \"subdivisions\"\n\n                text: subdivisionsValue.toFixed(1)\n                Layout.preferredWidth: textMetrics_subdivisionsValue.width\n                selectByMouse: true\n                validator: doubleValidator\n                onAccepted: {\n                    subdivisionsCtrl.value = Number(subdivisionsLabel.text)\n                }\n            }\n            Slider {\n                id: subdivisionsCtrl\n                Layout.fillWidth: false\n                from: 2\n                to: 72\n                value: subdivisionsDefaultValue\n                stepSize: 2\n            }\n        }\n        Rectangle{\n            color: root.palette.window\n            Layout.fillWidth: true\n        }\n        RowLayout{\n            ToolButton {\n                text: \"Edit Speed\"\n\n                ToolTip.visible: ToolTip.text && hovered\n                ToolTip.delay: 100\n                ToolTip.text: \"Reset the mouse multiplier\"\n\n                onClicked: {\n                    speedSpinBox.value = 1\n                }\n            }\n            SpinBox {\n                id: speedSpinBox\n                from: 1\n                value: 1\n                to: 10\n                stepSize: 1\n                Layout.fillWidth: false\n                Layout.maximumWidth: 50\n\n                validator: DoubleValidator {\n                    bottom: Math.min(speedSpinBox.from, speedSpinBox.to)\n                    top: Math.max(speedSpinBox.from, speedSpinBox.to)\n                }\n\n                textFromValue: function(value, locale) {\n                    return \"x\" + value.toString()\n\n                }\n            }\n        }\n        RowLayout{\n            ToolButton {\n                text: \"Downscale\"\n\n                ToolTip.visible: ToolTip.text && hovered\n                ToolTip.delay: 100\n                ToolTip.text: \"Reset the downscale\"\n\n                onClicked: {\n                    downscaleSpinBox.value = downscaleDefaultValue\n                }\n            }\n            SpinBox {\n                id: downscaleSpinBox\n                from: 0\n                value: downscaleDefaultValue\n                to: 5\n                stepSize: 1\n                Layout.fillWidth: false\n                Layout.maximumWidth: 50\n\n                validator: DoubleValidator {\n                    bottom: Math.min(downscaleSpinBox.from, downscaleSpinBox.to)\n                    top: Math.max(downscaleSpinBox.from, downscaleSpinBox.to)\n                }\n\n                textFromValue: function(value, locale) {\n                    if (value === 0){\n                        return 1\n                    } else {\n                        return \"1/\" + Math.pow(2, value).toString()\n                    }\n                }\n            }\n        }\n    }\n\n    TextMetrics {\n        id: textMetrics_subdivisionsValue\n        font: subdivisionsLabel.font\n        text: \"100.00\"\n    }\n}\n"
  },
  {
    "path": "meshroom/ui/qml/Viewer/PanoramaViewer.qml",
    "content": "import QtQuick\n\nimport AliceVision 1.0 as AliceVision\nimport Utils 1.0\n\n/**\n * PanoramaViwer displays a list of Float Images\n * Requires QtAliceVision plugin.\n */\n\nAliceVision.PanoramaViewer {\n    id: root\n\n    width: 3000\n    height: 1500\n\n    visible: (status === Image.Ready)\n\n    // paintedWidth / paintedHeight / status for compatibility with standard Image\n    property int paintedWidth: sourceSize.width\n    property int paintedHeight: sourceSize.height\n    property var status: {\n        if (readyToLoad === Image.Ready) {\n            return Image.Ready\n        } else {\n            return Image.Null\n        }\n    }\n\n    property int readyToLoad: Image.Null\n\n    property int subdivisionsPano: 12\n\n    property bool isEditable: true\n\n    property bool isHighlightable: true\n\n    property bool displayGridPano: true\n\n    property int mouseMultiplier: 1\n\n    property bool cropFisheyePano: false\n\n    property int idSelected : -1\n\n    onIsHighlightableChanged: {\n        for (var i = 0; i < repeater.model; ++i) {\n            repeater.itemAt(i).item.onChangedHighlightState(isHighlightable)\n        }\n    }\n\n    property alias containsMouse: mouseAreaPano.containsMouse\n\n    property bool isRotating: false\n    property double lastX : 0\n    property double lastY: 0\n\n    property double xStart : 0\n    property double yStart : 0\n\n    property double previous_yaw: 0;\n    property double previous_pitch: 0;\n    property double previous_roll: 0;\n\n    property double yaw: 0;\n    property double pitch: 0;\n    property double roll: 0;\n\n    property var activeNode: _currentScene.activeNodes.get('SfMTransform').node\n\n    // Yaw and Pitch in Degrees from SfMTransform node sliders\n    property double yawNode: activeNode ? activeNode.attribute(\"manualTransform.manualRotation.y\").value : 0\n    property double pitchNode: activeNode ? activeNode.attribute(\"manualTransform.manualRotation.x\").value : 0\n    property double rollNode: activeNode ? activeNode.attribute(\"manualTransform.manualRotation.z\").value : 0\n\n    // Convert angle functions\n    function toDegrees(radians) {\n        return radians * (180 / Math.PI)\n    }\n\n    function toRadians(degrees) {\n        return degrees * (Math.PI / 180)\n    }\n\n    function fmod(a,b) {\n        return Number((a - (Math.floor(a / b) * b)).toPrecision(8))\n    }\n\n    // Limit angle between -180 and 180\n    function limitAngle(angle) {\n        if (angle > 180)\n            angle = -180.0 + (angle - 180.0)\n        if (angle < -180)\n            angle = 180.0 - (Math.abs(angle) - 180)\n        return angle\n    }\n\n    function limitPitch(angle) {\n        return (angle > 180 || angle < -180) ? root.pitch : angle\n    }\n\n    onYawNodeChanged: {\n        root.yaw = yawNode\n    }\n    onPitchNodeChanged: {\n        root.pitch = pitchNode\n    }\n    onRollNodeChanged: {\n        root.roll = rollNode\n    }\n\n    Item {\n        id: containerPanorama\n        z: 10\n        Rectangle {\n            width: 3000\n            height: 1500\n            color: \"transparent\"\n            MouseArea {\n                id: mouseAreaPano\n                anchors.fill: parent\n                hoverEnabled: true\n                enabled: allImagesLoaded\n                cursorShape: {\n                    if (isEditable)\n                        isRotating ? Qt.ClosedHandCursor : Qt.OpenHandCursor\n                }\n                onPositionChanged: function(mouse) {\n                    // Send Mouse Coordinates to Float Images Viewers\n                    idSelected = -1\n                    for (var i = 0; i < repeater.model && isHighlightable; ++i) {\n                        var highlight = repeater.itemAt(i).item.getMouseCoordinates(mouse.x, mouse.y)\n                        repeater.itemAt(i).z = highlight ? 2 : 0\n                        if (highlight) {\n                            idSelected = root.msfmData.viewsIds[i]\n                        }\n                    }\n\n                    // Rotate Panorama\n                    if (isRotating && isEditable) {\n                        var nx = Math.min(width - 1, mouse.x)\n                        var ny = Math.min(height - 1, mouse.y)\n\n                        var xoffset = nx - lastX;\n                        var yoffset = ny - lastY;\n\n                        if (xoffset != 0 || yoffset !=0) {\n                            var latitude_start = (yStart / height) * Math.PI - (Math.PI / 2);\n                            var longitude_start = ((xStart / width) * 2 * Math.PI) - Math.PI;\n                            var latitude_end = (ny / height) * Math.PI - ( Math.PI / 2);\n                            var longitude_end = ((nx / width) * 2 * Math.PI) - Math.PI;\n\n                            var start_pt = Qt.vector2d(latitude_start, longitude_start)\n                            var end_pt = Qt.vector2d(latitude_end, longitude_end)\n\n                            var previous_euler = Qt.vector3d(previous_yaw, previous_pitch, previous_roll)\n                            var result\n\n                            if (mouse.modifiers & Qt.ControlModifier) {\n                                result = Transformations3DHelper.updatePanoramaInPlane(previous_euler, start_pt, end_pt)\n                                root.pitch = result.x\n                                root.yaw = result.y\n                                root.roll = result.z\n                            } else {\n                                result = Transformations3DHelper.updatePanorama(previous_euler, start_pt, end_pt)\n                                root.pitch = result.x\n                                root.yaw = result.y\n                                root.roll = result.z  \n                            }               \n                        }\n\n                        _currentScene.setAttribute(activeNode.attribute(\"manualTransform.manualRotation.x\"), Math.round(root.pitch))\n                        _currentScene.setAttribute(activeNode.attribute(\"manualTransform.manualRotation.y\"), Math.round(root.yaw))\n                        _currentScene.setAttribute(activeNode.attribute(\"manualTransform.manualRotation.z\"), Math.round(root.roll))\n                    }\n                }\n\n                onPressed: function(mouse) {\n                    _currentScene.beginModification(\"Panorama Manual Rotation\")\n                    isRotating = true\n                    lastX = mouse.x\n                    lastY = mouse.y\n\n                    xStart = mouse.x\n                    yStart = mouse.y\n\n                    previous_yaw = yaw\n                    previous_pitch = pitch\n                    previous_roll = roll\n                }\n\n                onReleased: function(mouse) {\n                    _currentScene.endModification()\n                    isRotating = false\n                    lastX = 0\n                    lastY = 0\n\n                    // Select the image in the image gallery if clicked\n                    if (xStart == mouse.x && yStart == mouse.y && idSelected != -1) {\n                        _currentScene.selectedViewId = idSelected\n                    }\n                }\n            }\n\n            // Grid Panorama Viewer\n            Canvas {\n                id: gridPano\n                visible: displayGridPano\n                anchors.fill : parent\n                property int wgrid: 40\n                onPaint: {\n                    var ctx = getContext(\"2d\")\n                    ctx.lineWidth = 1.0\n                    ctx.shadowBlur = 0\n                    ctx.strokeStyle = \"grey\"\n                    var nrows = height / wgrid\n                    for (var i = 0; i < nrows + 1; ++i) {\n                        ctx.moveTo(0, wgrid * i)\n                        ctx.lineTo(width, wgrid * i)\n                    }\n\n                    var ncols = width / wgrid\n                    for (var j = 0; j < ncols + 1; ++j) {\n                        ctx.moveTo(wgrid * j, 0)\n                        ctx.lineTo(wgrid * j, height)\n                    }\n\n                    ctx.closePath()\n                    ctx.stroke()\n                }\n            }\n        }\n    }\n\n    property int imagesLoaded: 0\n    property bool allImagesLoaded: false\n\n    function loadRepeaterImages(index) {\n        if (index < repeater.model)\n            repeater.itemAt(index).loadItem()\n        else\n            allImagesLoaded = true\n    }\n\n    Item {\n        id: panoImages\n        width: root.width\n        height: root.height\n\n        Component {\n            id: imgPano\n            Loader {\n                id: floatOneLoader\n                active: root.readyToLoad\n                visible: (floatOneLoader.status === Loader.Ready)\n                z: 0\n                property bool imageLoaded: false\n                property bool loading: false\n\n                onImageLoadedChanged: {\n                    imagesLoaded++\n                    loadRepeaterImages(imagesLoaded)\n                }\n\n                function loadItem() {\n                    if (!active)\n                        return\n\n                    if (loading) {\n                        loadRepeaterImages(index + 1)\n                        return\n                    }\n\n                    loading = true\n\n                    var idViewItem = msfmData.viewsIds[index]\n                    var sourceItem = Filepath.stringToUrl(msfmData.getUrlFromViewId(idViewItem))\n\n                    setSource(\"FloatImage.qml\", {\n                        \"surface.viewerType\": AliceVision.Surface.EViewerType.PANORAMA,\n                        \"viewerTypeString\": \"panorama\",\n                        \"surface.subdivisions\": Qt.binding(function() { return subdivisionsPano }),\n                        \"cropFisheye\" : Qt.binding(function(){ return cropFisheyePano }),\n                        \"surface.pitch\": Qt.binding(function() { return root.pitch }),\n                        \"surface.yaw\": Qt.binding(function() { return root.yaw }),\n                        \"surface.roll\": Qt.binding(function() { return root.roll }),\n                        \"idView\": Qt.binding(function() { return idViewItem }),\n                        \"gamma\": Qt.binding(function() { return hdrImageToolbar.gammaValue }),\n                        \"gain\": Qt.binding(function() { return hdrImageToolbar.gainValue }),\n                        \"channelModeString\": Qt.binding(function() { return hdrImageToolbar.channelModeValue }),\n                        \"downscaleLevel\": Qt.binding(function() { return downscale }),\n                        \"source\":  Qt.binding(function() { return sourceItem }),\n                        \"surface.msfmData\": Qt.binding(function() { return root.msfmData }),\n                        \"canBeHovered\": true,\n                        \"useSequence\": false\n                    })\n                    imageLoaded = Qt.binding(function() { return repeater.itemAt(index).item.imageStatus === Image.Ready ? true : false })\n                }\n            }\n        }\n        Repeater {\n            id: repeater\n            model: 0\n            delegate: imgPano\n        }\n        Connections {\n            target: root\n            function onDownscaleReady() {\n                root.imagesLoaded = 0\n\n                // Retrieve downscale value from C++\n                panoramaViewerToolbar.updateDownscaleValue(root.downscale)\n\n                // Changing the repeater model (number of elements)\n                panoImages.updateRepeater()\n\n                root.readyToLoad = Image.Ready\n\n                // Load images two by two\n                loadRepeaterImages(0)\n                loadRepeaterImages(1)\n            }\n        }\n\n        function updateRepeater() {\n            if (repeater.model !== root.msfmData.viewsIds.length) {\n                repeater.model = 0\n            }\n            repeater.model = root.msfmData.viewsIds.length\n        }\n    }\n}\n"
  },
  {
    "path": "meshroom/ui/qml/Viewer/PhongImageViewer.qml",
    "content": "import QtQuick\nimport Utils 1.0\n\nimport AliceVision 1.0 as AliceVision\n\n/**\n * PhongImageViewer displays an Image (albedo + normal) with a given light direction.\n * Shading is done using Blinn-Phong reflection model, material and light direction parameters available.\n * Accept HdrImageToolbar controls (gamma / offset / channel).\n *\n * <!> Requires QtAliceVision plugin.\n */\n\nAliceVision.PhongImageViewer {\n    id: root\n\n    width: sourceSize.width\n    height: sourceSize.height\n    visible: true\n\n    // paintedWidth / paintedHeight / imageStatus for compatibility with standard Image\n    property int paintedWidth: sourceSize.width\n    property int paintedHeight: sourceSize.height\n    property var imageStatus: {\n        if (root.status === AliceVision.PhongImageViewer.EStatus.LOADING) {\n            return Image.Loading\n        } else if (root.status === AliceVision.PhongImageViewer.EStatus.LOADING_ERROR ||\n                   root.status === AliceVision.PhongImageViewer.EStatus.MISSING_FILE) {\n            return Image.Error\n        } else if ((root.sourcePath === \"\") || (root.sourceSize.height <= 0) || (root.sourceSize.width <= 0)) {\n            return Image.Null\n        }\n\n        return Image.Ready\n    }\n\n    property string channelModeString : \"rgba\"\n    channelMode: {\n        switch (channelModeString) {\n            case \"rgb\": return AliceVision.PhongImageViewer.EChannelMode.RGB\n            case \"r\": return AliceVision.PhongImageViewer.EChannelMode.R\n            case \"g\": return AliceVision.PhongImageViewer.EChannelMode.G\n            case \"b\": return AliceVision.PhongImageViewer.EChannelMode.B\n            case \"a\": return AliceVision.PhongImageViewer.EChannelMode.A\n            default: return AliceVision.PhongImageViewer.EChannelMode.RGBA\n        }\n    }\n}\n"
  },
  {
    "path": "meshroom/ui/qml/Viewer/PhongImageViewerToolbar.qml",
    "content": "import QtQuick\nimport QtQuick.Controls\nimport QtQuick.Layouts\nimport QtQuick.Dialogs\nimport MaterialIcons 2.2\nimport Controls 1.0\nimport Utils 1.0\n\nFloatingPane {\n    id: root\n\n    property color baseColorValue: colorText.text\n    property real textureOpacityValue: textureTF.text\n    property real kaValue: ambientTF.text\n    property real kdValue: diffuseTF.text\n    property real ksValue: specularTF.text\n    property real shininessValue: shininessTF.text\n    property bool displayLightController: true\n\n    function reset () {\n        colorText.text = \"#333333\"\n        textureCtrl.value = 1.0\n        ambientCtrl.value = 0.0\n        diffuseCtrl.value = 1.0\n        specularCtrl.value = 0.5\n        shininessCtrl.value = 20.0\n    }\n\n    anchors.margins: 0\n    padding: 5\n    radius: 0\n\n    ColumnLayout {\n        id: phongLightingParameters\n        anchors.fill: parent\n        spacing: 5\n\n        // header\n        RowLayout {\n            // pane title\n            Label {\n                text: _currentScene && _currentScene.activeNodes.get(\"PhotometricStereo\").node ? _currentScene.activeNodes.get(\"PhotometricStereo\").node.label : \"\"\n                font.bold: true\n                Layout.fillWidth: true\n            }\n\n            // minimize or maximize button\n            MaterialToolButton {\n                id: bodyButton\n                text: phongLightingToolbarBody.visible ? MaterialIcons.arrow_drop_down : MaterialIcons.arrow_drop_up\n                font.pointSize: 10\n                ToolTip.text: phongLightingToolbarBody.visible ? \"Minimize\" : \"Maximize\"\n                onClicked: { phongLightingToolbarBody.visible = !phongLightingToolbarBody.visible }\n            }\n\n            // reset button\n            MaterialToolButton {\n                id: resetButton\n                text: MaterialIcons.refresh\n                font.pointSize: 10\n                ToolTip.text: \"Reset\"\n                onClicked: reset()\n            }\n\n            // settings menu\n            MaterialToolButton {\n                text: MaterialIcons.settings\n                font.pointSize: 10\n                onClicked: settingsMenu.popup(width, 0)\n                Menu {\n                    id: settingsMenu\n                    padding: 4\n                    implicitWidth: 250\n\n                    RowLayout {\n                        Label {\n                            text: \"Display Directional Light Contoller:\"\n                        }\n                        CheckBox {\n                            id: displayLightControllerCB\n                            ToolTip.text: \"Hides directional light controller.\"\n                            ToolTip.visible: hovered\n                            Layout.fillHeight: true\n                            Layout.alignment: Qt.AlignRight\n                            checked: root.displayLightController\n                            onClicked: root.displayLightController = displayLightControllerCB.checked\n                        }\n                    }\n                }\n            }\n        }\n        \n        // body\n        GridLayout {\n            id: phongLightingToolbarBody\n            columns: 3\n            rowSpacing: 2\n            columnSpacing: 8\n\n            // base color\n            Label {\n                text: \"Base Color\"\n            }\n            Rectangle {\n                height: colorText.height * 0.8\n                color: colorText.text\n                Layout.alignment: Qt.AlignVCenter | Qt.AlignRight\n                Layout.preferredWidth: textMetricsNormValue.width\n\n                MouseArea {\n                    anchors.fill: parent\n                    onClicked: colorDialog.open()\n                }\n            }\n            TextField {\n                id: colorText\n                text: \"#333333\"\n                selectByMouse: true\n                Layout.alignment: Qt.AlignLeft\n                Layout.fillWidth: true \n            }\n            ColorDialog {\n                id: colorDialog\n                title: \"Please choose a color\"\n                options: ColorDialog.NoEyeDropperButton \n                selectedColor: colorText.text\n                onAccepted: {\n                    colorText.text = selectedColor\n                    colorText.editingFinished() // artificially trigger change of attribute value\n                    close()\n                }\n                onRejected: close()\n            }\n\n            // texture opacity\n            Label {\n                text: \"Texture\"\n            }\n            TextField {\n                id: textureTF\n                text: textureCtrl.value.toFixed(2)\n                selectByMouse: true\n                horizontalAlignment: TextInput.AlignRight\n                validator: doubleNormalizedValidator\n                onEditingFinished: { textureCtrl.value = textureTF.text }\n                ToolTip.text: \"Texture Opacity.\"\n                ToolTip.visible: hovered\n                Layout.preferredWidth: textMetricsNormValue.width\n            }\n            Slider {\n                id: textureCtrl\n                from: 0.0\n                to: 1.0\n                value: 1.0\n                stepSize: 0.01\n                Layout.fillWidth: true                                       \n            }\n\n            // diffuse (kd)\n            Label {\n                text: \"Diffuse\"\n            }\n            TextField {\n                id: diffuseTF\n                text: diffuseCtrl.value.toFixed(2)\n                selectByMouse: true\n                horizontalAlignment: TextInput.AlignRight\n                validator: doubleNormalizedValidator\n                onEditingFinished: { diffuseCtrl.value = diffuseTF.text }\n                ToolTip.text: \"Diffuse reflection (kd).\"\n                ToolTip.visible: hovered\n                Layout.preferredWidth: textMetricsNormValue.width\n            }\n            Slider {\n                id: diffuseCtrl\n                from: 0.0\n                to: 1.0\n                value: 1.0\n                stepSize: 0.01\n                Layout.fillWidth: true                                       \n            }\n\n            // ambient (ka)\n            Label {\n                text: \"Ambient\"\n            }\n            TextField {\n                id: ambientTF\n                text: ambientCtrl.value.toFixed(2)\n                selectByMouse: true\n                horizontalAlignment: TextInput.AlignRight\n                validator: doubleNormalizedValidator\n                onEditingFinished: { ambientCtrl.value = ambientTF.text }\n                ToolTip.text: \"Ambient reflection (ka).\"\n                ToolTip.visible: hovered\n                Layout.preferredWidth: textMetricsNormValue.width\n            }\n            Slider {\n                id: ambientCtrl\n                from: 0.0\n                to: 1.0\n                value: 0.0\n                stepSize: 0.01 \n                Layout.fillWidth: true                                 \n            }\n\n            // specular (ks)\n            Label {\n                text: \"Specular\"\n            }\n            TextField {\n                id: specularTF\n                text: specularCtrl.value.toFixed(2)\n                selectByMouse: true\n                horizontalAlignment: TextInput.AlignRight\n                validator: doubleNormalizedValidator\n                onEditingFinished: { specularCtrl.value = specularTF.text }\n                ToolTip.text: \"Specular reflection (ks).\"\n                ToolTip.visible: hovered\n                Layout.preferredWidth: textMetricsNormValue.width\n            }\n            Slider {\n                id: specularCtrl\n                from: 0.0\n                to: 1.0\n                value: 0.5\n                stepSize: 0.01\n                Layout.fillWidth: true                                         \n            }\n\n            // shininess\n            Label {\n                text: \"Shininess\"\n            }\n            TextField {\n                id: shininessTF\n                text: shininessCtrl.value\n                selectByMouse: true\n                horizontalAlignment: TextInput.AlignRight\n                validator: intShininessValidator\n                onEditingFinished: { shininessCtrl.value = shininessTF.text }\n                ToolTip.text: \"Shininess constant.\"\n                ToolTip.visible: hovered\n                Layout.preferredWidth: textMetricsNormValue.width\n            }\n            Slider {\n                id: shininessCtrl\n                from: 1\n                to: 128\n                value: 20\n                stepSize: 1\n                Layout.fillWidth: true                                         \n            }\n        }\n    }\n\n    DoubleValidator {\n        id: doubleNormalizedValidator\n        locale: 'C' // use '.' decimal separator disregarding of the system locale\n        bottom: 0.0\n        top: 1.0\n    }\n\n    IntValidator {\n        id: intShininessValidator\n        bottom: 1\n        top: 128\n    }\n\n    TextMetrics {\n        id: textMetricsNormValue\n        font: ambientTF.font\n        text: \"1.2345\" \n    }\n}"
  },
  {
    "path": "meshroom/ui/qml/Viewer/SequencePlayer.qml",
    "content": "import QtCore\nimport QtQuick\nimport QtQuick.Controls\nimport QtQuick.Layouts\n\nimport Controls 1.0\nimport MaterialIcons 2.2\nimport Utils 1.0\n\n/**\n * The Sequence Player is a UI for manipulating\n * the currently selected (and displayed) viewpoint\n * in an ordered set of viewpoints (i.e. a sequence).\n *\n * The viewpoint manipulation process can be manual\n * (for example by dragging a slider to change the current frame)\n * or automatic\n * (by playing the sequence, i.e. incrementing the current frame at a given time rate).\n */\nFloatingPane {\n    id: root\n\n    // Exposed properties\n    property var sortedViewIds: []\n    property var viewer: null\n    property bool isOutputSequence: false\n    readonly property alias sync3DSelected: m.sync3DSelected\n    readonly property alias syncFeaturesSelected: m.syncFeaturesSelected\n    property bool loading: fetchButton.checked || m.playing\n    property alias settings_SequencePlayer: settings_SequencePlayer\n    property alias frameId: m.frame\n    property var frameRange: { \"min\" : 0, \"max\" : 0 }\n\n    Settings {\n        id: settings_SequencePlayer\n        property int maxCacheMemory: viewer && viewer.ramInfo != undefined ? viewer.ramInfo.x / 4 : 0\n    }\n\n    function updateSceneView() {\n        if (isOutputSequence)\n            return\n        if (_currentScene && m.frame >= frameRange.min && m.frame < frameRange.max + 1) {\n            if (!m.playing && !frameSlider.pressed) {\n                _currentScene.selectedViewId = sortedViewIds[m.frame]\n            } else {\n                _currentScene.pickedViewId = sortedViewIds[m.frame]\n                if (m.sync3DSelected) {\n                    _currentScene.updateSelectedViewpoint(_currentScene.pickedViewId)\n                }\n            }\n        }\n    }\n\n    onIsOutputSequenceChanged: {\n        if (!isOutputSequence) {\n            frameId = frameRange.min\n        }\n    }\n\n    onSortedViewIdsChanged: {\n        frameSlider.from = frameRange.min\n        frameSlider.to = frameRange.max\n    }\n\n    // Sequence player model:\n    // - current frame\n    // - data related to automatic sequence playing\n    QtObject {\n        id: m\n\n        property int frame: frameRange.min\n        property bool syncFeaturesSelected: true\n        property bool sync3DSelected: true\n        property bool playing: false\n        property bool repeat: false\n        property real fps: 24\n\n        onFrameChanged: {\n            updateSceneView()\n        }\n\n        onPlayingChanged: {\n            if (!playing) {\n                updateSceneView()\n            } else if (playing && (frame + 1 >= frameRange.max + 1)) {\n                frame = frameRange.min\n            }\n            viewer.playback(playing)\n        }\n    }\n\n    // Update the frame property\n    // when the selected view ID is changed externally\n    Connections {\n        target: _currentScene\n        function onSelectedViewIdChanged() {\n            for (let idx = 0; idx < sortedViewIds.length; idx++) {\n                if (_currentScene.selectedViewId === sortedViewIds[idx] && (m.frame != idx)) {\n                    m.frame = idx\n                }\n            }\n        }\n    }\n\n    // In play mode\n    // we use a timer to increment the frame property\n    // at a given time rate (defined by the fps property)\n    Timer {\n        id: timer\n\n        repeat: true\n        running: m.playing && root.visible\n        interval: 1000 / m.fps\n\n        onTriggered: {\n            if (viewer.imageStatus !== Image.Ready) {\n                // Wait for current image to be displayed before switching to next image\n                return\n            }\n            let nextIndex = m.frame + 1\n            if (nextIndex == frameRange.max + 1) {\n                if (m.repeat) {\n                    m.frame = frameRange.min\n                    return\n                }\n                else {\n                    m.playing = false\n                    return\n                }\n            }\n            m.frame = nextIndex\n        }\n    }\n    \n    // Widgets:\n    // - \"Previous Frame\" button\n    // - \"Play - Pause\" button\n    // - \"Next Frame\" button\n    // - frame label\n    // - frame slider\n    // - FPS spin box\n    // - \"Repeat\" button\n    RowLayout {\n\n        anchors.fill: parent \n\n        IntSelector {\n            id: frameInput\n\n            tooltipText: \"Frame\"\n            displayButtons: true\n\n            range: frameRange\n\n            onValueChanged: {\n                m.frame = value\n            }\n\n            Binding {\n                target: frameInput\n                property: \"value\"\n                value: m.frame\n                when: !frameInput.activeFocus\n            }\n        }\n\n        MaterialToolButton {\n            id: playButton\n\n            checkable: true\n            checked: false\n            text: checked ? MaterialIcons.pause_circle : MaterialIcons.play_circle\n            font.pointSize: 20\n            ToolTip.text: checked ? \"Pause Player\" : \"Play Sequence\"\n\n            onCheckedChanged: {\n                m.playing = checked\n            }\n\n            Connections {\n                target: m\n                function onPlayingChanged() {\n                    playButton.checked = m.playing\n                }\n            }\n        }\n\n    \n        Slider {\n            id: frameSlider\n\n            Layout.fillWidth: true\n\n            value: m.frame\n\n            stepSize: 1\n            snapMode: Slider.SnapAlways\n            live: true\n\n            from: frameRange.min\n            to: frameRange.max\n\n            onValueChanged: {\n                m.frame = value\n            }\n\n            onPressedChanged: {\n                if (!pressed) {\n                    updateSceneView()\n                }\n            }\n\n            ToolTip {\n                parent: frameSlider.handle\n                visible: frameSlider.hovered\n                text: m.frame\n            }\n\n\n            background: Rectangle {\n                x: frameSlider.leftPadding\n                y: frameSlider.topPadding + frameSlider.height / 2 - height / 2\n                width: frameSlider.availableWidth\n                height: 4\n                radius: 2\n                color: Colors.grey\n\n                Repeater {\n                    id: cacheView\n\n                    model: viewer ? viewer.cachedFrames : []\n                    property real frameLength: sortedViewIds.length > 0 ? frameSlider.width / (frameRange.max - frameRange.min + 1) : 0\n\n                    Rectangle {\n                        x: modelData.x * cacheView.frameLength\n                        y: 0\n                        width: cacheView.frameLength * (modelData.y - modelData.x + 1)\n                        height: 4\n                        radius: 2\n                        color: Colors.blue\n                    }\n                }\n            }\n        }\n\n        RowLayout {\n            TextInput {\n                id: fpsTextInput\n\n                Layout.preferredWidth: fpsMetrics.width\n                selectByMouse: true\n\n                text: !focus ? m.fps + \" FPS\" : m.fps\n                color: palette.text\n\n                onEditingFinished: {\n                    m.fps = parseInt(text)\n                    focus = false\n                }\n            }\n        }\n\n        MaterialToolButton {\n            id: fetchButton\n\n            text: MaterialIcons.subscriptions\n            ToolTip.text: \"Fetch\"\n            checkable: true\n            checked: loading\n        }\n\n        MaterialToolButton {\n            id: repeatButton\n\n            checkable: true\n            checked: false\n            text: MaterialIcons.repeat\n            ToolTip.text: \"Repeat\"\n\n            onCheckedChanged: {\n                m.repeat = checked\n            }\n        }\n\n        MaterialToolButton {\n            id: infoButton\n\n            text: MaterialIcons.settings\n            font.pointSize: 11\n            padding: 2\n            onClicked: infoMenu.open()\n            checkable: true\n            checked: infoMenu.visible\n\n            Popup {\n                id: infoMenu\n                y: parent.height\n                x: -width + parent.width\n\n                contentItem: GridLayout {\n                        layoutDirection: Qt.LeftToRight\n                        columns: 2\n\n                        Column {\n                            id: syncColumn\n                            Layout.alignment: Qt.AlignTop\n\n                            Text {\n                                text: \"<b>Synchronisation:</b>\"\n                                color: palette.text\n                            }\n\n                            CheckBox {\n                                id: syncFeaturePointsCheckBox\n                                text: \"Sync Feature Points\"\n                                checkable: true\n                                checked: m.syncFeaturesSelected\n                                onCheckedChanged: {\n                                    m.syncFeaturesSelected = checked\n                                }\n\n                                ToolTip.text: \"The Feature points will be updated at the same time as the Sequence Player.\"\n                                ToolTip.visible: hovered\n                                ToolTip.delay: 100\n                            }\n\n                            CheckBox {\n                                id: sync3DCheckBox\n                                text: \"Sync 3D Viewer\"\n                                checkable: true\n                                checked: m.sync3DSelected\n                                onCheckedChanged: {\n                                    m.sync3DSelected = checked\n                                }\n\n                                ToolTip.text: \"The 3D Viewer will be updated at the same time as the Sequence Player.\"\n                                ToolTip.visible: hovered\n                                ToolTip.delay: 100\n                            }\n                        }\n\n                        Column {\n                            id: cacheColumn\n                            Layout.alignment: Qt.AlignTop\n\n                            Text {\n                                text: \"<b>Cache:</b>\"\n                                color: palette.text\n                            }\n\n                            // max cache memory limit\n                            Row {\n                                height: sync3DCheckBox.height\n                                Text {\n                                    anchors.verticalCenter: parent.verticalCenter\n                                    text: \"Max Cache Memory: \"\n                                    color: palette.text\n                                }\n                                \n                                TextField {\n                                    id: maxCacheMemoryInput\n\n                                    anchors.verticalCenter: parent.verticalCenter\n                                    color: palette.text\n\n                                    text: !focus ? settings_SequencePlayer.maxCacheMemory + \" GB\" : settings_SequencePlayer.maxCacheMemory\n\n                                    onEditingFinished: {\n                                        settings_SequencePlayer.maxCacheMemory = parseInt(text)\n                                        focus = false\n                                    }\n                                }\n                            }\n\n                            Text {\n                                height: sync3DCheckBox.height\n                                verticalAlignment: Text.AlignVCenter\n                                text: {\n                                    if (viewer && viewer.ramInfo != undefined)\n                                        return \"Available Memory: \" + viewer.ramInfo.x + \" GB\"\n                                    return \"Unknown Available Memory\"\n                                }\n                                color: palette.text\n                            }\n\n                            Text {\n                                height: sync3DCheckBox.height\n                                verticalAlignment: Text.AlignVCenter\n                                text: {\n                                    // number of cached frames is the difference between the first and last frame of all intervals in the cache\n                                    let cachedFrames = viewer ? viewer.cachedFrames : []\n                                    let cachedFramesCount = 0\n                                    for (let i = 0; i < cachedFrames.length; i++) {\n                                        cachedFramesCount += cachedFrames[i].y - cachedFrames[i].x + 1\n                                    }\n                                    return \"Cached Frames: \" + (viewer ? cachedFramesCount : \"0\") + \" / \" + sortedViewIds.length\n                                }\n                                color: palette.text\n                            }\n\n                            // do beautiful progress bar\n                            ProgressBar {\n                                id: cacheProgressBar\n\n                                width: parent.width\n\n                                from: 0\n                                to: viewer && viewer.ramInfo != undefined ? viewer.ramInfo.x : 0\n\n                                value: viewer ? settings_SequencePlayer.maxCacheMemory : 0\n\n                                ToolTip.text: {\n                                    let ramMsg = \"Max cache memory set: \" + settings_SequencePlayer.maxCacheMemory + \" GB\"\n                                    if (viewer && viewer.ramInfo != undefined) {\n                                        return  ramMsg + \"\\n\" + \"on available memory: \" + viewer.ramInfo.x + \" GB\"\n                                    }\n                                    return ramMsg + \",\\n\" + \"available memory unknown\"\n                                }\n                                ToolTip.visible: hovered\n                                ToolTip.delay: 100\n                            }\n\n                            ProgressBar {\n                                id: occupiedCacheProgressBar\n\n                                property string occupiedCache: viewer && viewer.ramInfo ? Format.GB2SizeStr(viewer.ramInfo.y) : 0\n\n                                width: parent.width\n\n                                from: 0\n                                to: settings_SequencePlayer.maxCacheMemory\n                                value: viewer && viewer.ramInfo != undefined ? viewer.ramInfo.y : 0\n\n                                ToolTip.text: \"Occupied cache: \" + occupiedCache + \"\\n\" + \"On max cache memory set: \" + settings_SequencePlayer.maxCacheMemory + \" GB\"\n                                ToolTip.visible: hovered\n                                ToolTip.delay: 100\n                            }\n                            \n                        }\n                    }\n            }\n        }\n    }\n\n    TextMetrics {\n        id: fpsMetrics\n\n        font: fpsTextInput.font\n        text: \"100 FPS\"\n    }\n\n    // Action to play/pause the sequence player\n    Action {\n        id: playPauseAction\n\n        shortcut: \"Space\"\n\n        onTriggered: {\n            m.playing = !m.playing\n        }\n    }\n}\n"
  },
  {
    "path": "meshroom/ui/qml/Viewer/SfmGlobalStats.qml",
    "content": "import QtCharts\nimport QtQuick\nimport QtQuick.Controls\nimport QtQuick.Layouts\n\nimport AliceVision 1.0 as AliceVision\nimport Charts 1.0\nimport Controls 1.0\nimport Utils 1.0\n\nFloatingPane {\n    id: root\n\n    property var msfmData\n    property var mTracks\n    property color textColor: Colors.sysPalette.text\n\n    visible: (_currentScene.sfm && _currentScene.sfm.isComputed) ? root.visible : false\n    clip: true\n    padding: 4\n\n    // To avoid interaction with components in background\n    MouseArea {\n        anchors.fill: parent\n        acceptedButtons: Qt.LeftButton | Qt.RightButton | Qt.MiddleButton\n        onPressed: {}\n        onReleased: {}\n        onWheel: {}\n    }\n\n\n   InteractiveChartView {\n        id: residualsPerViewChart\n        width: parent.width * 0.5\n        height: parent.height * 0.5\n\n        title: \"Residuals Per View\"\n        legend.visible: false\n        antialiasing: true\n\n        ValueAxis {\n            id: residualsPerViewValueAxisX\n            labelFormat: \"%i\"\n            titleText: \"Ordered Views\"\n            min: 0\n            max: sfmDataStat.residualsPerViewMaxAxisX\n        }\n        ValueAxis {\n            id: residualsPerViewValueAxisY\n            titleText: \"Reprojection Error (pix)\"\n            min: 0\n            max: sfmDataStat.residualsPerViewMaxAxisY\n            tickAnchor: 0\n            tickInterval: 0.50\n            tickCount: sfmDataStat.residualsPerViewMaxAxisY * 2\n        }\n        LineSeries {\n            id: residualsMinPerViewLineSerie\n            axisX: residualsPerViewValueAxisX\n            axisY: residualsPerViewValueAxisY\n            name: \"Min\"\n        }\n        LineSeries {\n            id: residualsMaxPerViewLineSerie\n            axisX: residualsPerViewValueAxisX\n            axisY: residualsPerViewValueAxisY\n            name: \"Max\"\n        }\n        LineSeries {\n            id: residualsMeanPerViewLineSerie\n            axisX: residualsPerViewValueAxisX\n            axisY: residualsPerViewValueAxisY\n            name: \"Mean\"\n        }\n        LineSeries {\n            id: residualsMedianPerViewLineSerie\n            axisX: residualsPerViewValueAxisX\n            axisY: residualsPerViewValueAxisY\n            name: \"Median\"\n        }\n        LineSeries {\n            id: residualsFirstQuartilePerViewLineSerie\n            axisX: residualsPerViewValueAxisX\n            axisY: residualsPerViewValueAxisY\n            name: \"Q1\"\n        }\n        LineSeries {\n            id: residualsThirdQuartilePerViewLineSerie\n            axisX: residualsPerViewValueAxisX\n            axisY: residualsPerViewValueAxisY\n            name: \"Q3\"\n        }\n    }\n\n    Item {\n        id: residualsPerViewBtnContainer\n\n        Layout.fillWidth: true\n        anchors.bottom: residualsPerViewChart.bottom\n        anchors.bottomMargin: 35\n        anchors.left: residualsPerViewChart.left\n        anchors.leftMargin: residualsPerViewChart.width * 0.25\n\n        RowLayout {\n            ChartViewCheckBox {\n                id: allObservations\n                text: \"ALL\"\n                color: textColor\n                checkState: residualsPerViewLegend.buttonGroup.checkState\n                onClicked: {\n                    var _checked = checked;\n                    for (var i = 0; i < residualsPerViewChart.count; ++i) {\n                        residualsPerViewChart.series(i).visible = _checked\n                    }\n                }\n            }\n\n            ChartViewLegend {\n                id: residualsPerViewLegend\n                chartView: residualsPerViewChart\n            }\n\n        }\n    }\n\n    InteractiveChartView {\n        id: observationsLengthsPerViewChart\n        width: parent.width * 0.5\n        height: parent.height * 0.5\n        anchors.top: parent.top\n        anchors.topMargin: (parent.height) * 0.5\n\n        title: \"Observations Lengths Per View\"\n        legend.visible: false\n        antialiasing: true\n\n        ValueAxis {\n            id: observationsLengthsPerViewValueAxisX\n            labelFormat: \"%i\"\n            titleText: \"Ordered Views\"\n            min: 0\n            max: sfmDataStat.observationsLengthsPerViewMaxAxisX\n        }\n        ValueAxis {\n            id: observationsLengthsPerViewValueAxisY\n            titleText: \"Observations Lengths\"\n            min: 0\n            max: sfmDataStat.observationsLengthsPerViewMaxAxisY\n            tickAnchor: 0\n            tickInterval: 0.50\n            tickCount: sfmDataStat.observationsLengthsPerViewMaxAxisY * 2\n        }\n\n        LineSeries {\n            id: observationsLengthsMinPerViewLineSerie\n            axisX: observationsLengthsPerViewValueAxisX\n            axisY: observationsLengthsPerViewValueAxisY\n            name: \"Min\"\n        }\n        LineSeries {\n            id: observationsLengthsMaxPerViewLineSerie\n            axisX: observationsLengthsPerViewValueAxisX\n            axisY: observationsLengthsPerViewValueAxisY\n            name: \"Max\"\n        }\n        LineSeries {\n            id: observationsLengthsMeanPerViewLineSerie\n            axisX: observationsLengthsPerViewValueAxisX\n            axisY: observationsLengthsPerViewValueAxisY\n            name: \"Mean\"\n        }\n        LineSeries {\n            id: observationsLengthsMedianPerViewLineSerie\n            axisX: observationsLengthsPerViewValueAxisX\n            axisY: observationsLengthsPerViewValueAxisY\n            name: \"Median\"\n        }\n        LineSeries {\n            id: observationsLengthsFirstQuartilePerViewLineSerie\n            axisX: observationsLengthsPerViewValueAxisX\n            axisY: observationsLengthsPerViewValueAxisY\n            name: \"Q1\"\n        }\n        LineSeries {\n            id: observationsLengthsThirdQuartilePerViewLineSerie\n            axisX: observationsLengthsPerViewValueAxisX\n            axisY: observationsLengthsPerViewValueAxisY\n            name: \"Q3\"\n        }\n    }\n\n    Item {\n        id: observationsLengthsPerViewBtnContainer\n\n        Layout.fillWidth: true\n        anchors.bottom: observationsLengthsPerViewChart.bottom\n        anchors.bottomMargin: 35\n        anchors.left: observationsLengthsPerViewChart.left\n        anchors.leftMargin: observationsLengthsPerViewChart.width * 0.25\n\n        RowLayout {\n            ChartViewCheckBox {\n                id: allModes\n                text: \"ALL\"\n                color: textColor\n                checkState: observationsLengthsPerViewLegend.buttonGroup.checkState\n                onClicked: {\n                    var _checked = checked;\n                    for (var i = 0; i < observationsLengthsPerViewChart.count; ++i) {\n                        observationsLengthsPerViewChart.series(i).visible = _checked\n                    }\n                }\n            }\n\n            ChartViewLegend {\n                id: observationsLengthsPerViewLegend\n                chartView: observationsLengthsPerViewChart\n            }\n        }\n    }\n\n    InteractiveChartView {\n        id: landmarksPerViewChart\n        width: parent.width * 0.5\n        height: parent.height * 0.5\n        anchors.left: parent.left\n        anchors.leftMargin: (parent.width) * 0.5\n        anchors.top: parent.top\n\n        title: \"Landmarks Per View\"\n        legend.visible: false\n        antialiasing: true\n\n        ValueAxis {\n            id: landmarksPerViewValueAxisX\n            titleText: \"Ordered Views\"\n            min: 0.0\n            max: sfmDataStat.landmarksPerViewMaxAxisX\n        }\n        ValueAxis {\n            id: landmarksPerViewValueAxisY\n            labelFormat: \"%i\"\n            titleText: \"Number of Landmarks\"\n            min: 0\n            max: sfmDataStat.landmarksPerViewMaxAxisY\n        }\n        LineSeries {\n            id: landmarksPerViewLineSerie\n            axisX: landmarksPerViewValueAxisX\n            axisY: landmarksPerViewValueAxisY\n            name: \"Landmarks\"\n        }\n        LineSeries {\n            id: tracksPerViewLineSerie\n            axisX: landmarksPerViewValueAxisX\n            axisY: landmarksPerViewValueAxisY\n            name: \"Tracks\"\n        }\n    }\n\n    Item {\n        id: landmarksFeatTracksPerViewBtnContainer\n\n        Layout.fillWidth: true\n        anchors.bottom: landmarksPerViewChart.bottom\n        anchors.bottomMargin: 35\n        anchors.left: landmarksPerViewChart.left\n        anchors.leftMargin: landmarksPerViewChart.width * 0.25\n\n        RowLayout {\n            ChartViewCheckBox {\n                id: allFeatures\n                text: \"ALL\"\n                color: textColor\n                checkState: landmarksFeatTracksPerViewLegend.buttonGroup.checkState\n                onClicked: {\n                    var _checked = checked;\n                    for (var i = 0; i < landmarksPerViewChart.count; ++i) {\n                        landmarksPerViewChart.series(i).visible = _checked\n                    }\n                }\n            }\n\n            ChartViewLegend {\n                id: landmarksFeatTracksPerViewLegend\n                chartView: landmarksPerViewChart\n            }\n        }\n    }\n\n    // Stats from the sfmData\n    AliceVision.MSfMDataStats {\n        id: sfmDataStat\n        msfmData: root.msfmData\n        mTracks: root.mTracks\n\n        onAxisChanged: {\n            fillLandmarksPerViewSerie(landmarksPerViewLineSerie)\n            fillTracksPerViewSerie(tracksPerViewLineSerie)\n            fillResidualsMinPerViewSerie(residualsMinPerViewLineSerie)\n            fillResidualsMaxPerViewSerie(residualsMaxPerViewLineSerie)\n            fillResidualsMeanPerViewSerie(residualsMeanPerViewLineSerie)\n            fillResidualsMedianPerViewSerie(residualsMedianPerViewLineSerie)\n            fillResidualsFirstQuartilePerViewSerie(residualsFirstQuartilePerViewLineSerie)\n            fillResidualsThirdQuartilePerViewSerie(residualsThirdQuartilePerViewLineSerie)\n            fillObservationsLengthsMinPerViewSerie(observationsLengthsMinPerViewLineSerie)\n            fillObservationsLengthsMaxPerViewSerie(observationsLengthsMaxPerViewLineSerie)\n            fillObservationsLengthsMeanPerViewSerie(observationsLengthsMeanPerViewLineSerie)\n            fillObservationsLengthsMedianPerViewSerie(observationsLengthsMedianPerViewLineSerie)\n            fillObservationsLengthsFirstQuartilePerViewSerie(observationsLengthsFirstQuartilePerViewLineSerie)\n            fillObservationsLengthsThirdQuartilePerViewSerie(observationsLengthsThirdQuartilePerViewLineSerie)\n        }\n    }\n}\n"
  },
  {
    "path": "meshroom/ui/qml/Viewer/SfmStatsView.qml",
    "content": "import QtCharts\nimport QtQuick\nimport QtQuick.Controls\nimport QtQuick.Layouts\n\nimport AliceVision 1.0 as AliceVision\nimport Charts 1.0\nimport Controls 1.0\nimport Utils 1.0\n\n\nFloatingPane {\n    id: root\n\n    property var msfmData: null\n    property int viewId\n    property color textColor: Colors.sysPalette.text\n\n    visible: (_currentScene.sfm && _currentScene.sfm.isComputed) ? root.visible : false\n    clip: true\n    padding: 4\n\n    // To avoid interaction with components in background\n    MouseArea {\n        anchors.fill: parent\n        acceptedButtons: Qt.LeftButton | Qt.RightButton | Qt.MiddleButton\n        onPressed: {}\n        onReleased: {}\n        onWheel: {}\n    }\n\n    InteractiveChartView {\n        id: residualChart\n        width: parent.width * 0.5\n        height: parent.height * 0.5\n\n        title: \"Reprojection Errors\"\n        legend.visible: false\n        antialiasing: true\n\n        ValueAxis {\n            id: residualValueAxisX\n            titleText: \"Reprojection Error\"\n            min: 0.0\n            max: viewStat.residualMaxAxisX\n        }\n        ValueAxis {\n            id: residualValueAxisY\n            labelFormat: \"%i\"\n            titleText: \"Number of Points\"\n            min: 0\n            max: viewStat.residualMaxAxisY\n        }\n        LineSeries {\n            id: residualFullLineSerie\n            axisX: residualValueAxisX\n            axisY: residualValueAxisY\n            name: \"Average on All Cameras\"\n        }\n        LineSeries {\n            id: residualViewLineSerie\n            axisX: residualValueAxisX\n            axisY: residualValueAxisY\n            name: \"Current\"\n        }\n    }\n\n    Item {\n        id: residualBtnContainer\n\n        Layout.fillWidth: true\n        anchors.bottom: residualChart.bottom\n        anchors.bottomMargin: 35\n        anchors.left: residualChart.left\n        anchors.leftMargin: residualChart.width * 0.15\n\n        RowLayout {\n\n            ChartViewCheckBox {\n                id: allResiduals\n                text: \"ALL\"\n                color: textColor\n                checkState: residualLegend.buttonGroup.checkState\n                onClicked: {\n                    var _checked = checked;\n                    for (var i = 0; i < residualChart.count; ++i) {\n                        residualChart.series(i).visible = _checked\n                    }\n                }\n            }\n\n            ChartViewLegend {\n                id: residualLegend\n                chartView: residualChart\n            }\n        }\n    }\n\n    InteractiveChartView {\n        id: observationsLengthsChart\n        width: parent.width * 0.5\n        height: parent.height * 0.5\n        anchors.top: parent.top\n        anchors.topMargin: (parent.height) * 0.5\n\n        legend.visible: false\n        title: \"Observations Lengths\"\n\n        ValueAxis {\n            id: observationsLengthsvalueAxisX\n            labelFormat: \"%i\"\n            titleText: \"Observations Length\"\n            min: 2\n            max: viewStat.observationsLengthsMaxAxisX\n            tickAnchor: 2\n            tickInterval: 1\n            tickCount: 5\n        }\n        ValueAxis {\n            id: observationsLengthsvalueAxisY\n            labelFormat: \"%i\"\n            titleText: \"Number of Points\"\n            min: 0\n            max: viewStat.observationsLengthsMaxAxisY\n        }\n        LineSeries {\n            id: observationsLengthsFullLineSerie\n            axisX: observationsLengthsvalueAxisX\n            axisY: observationsLengthsvalueAxisY\n            name: \"All Cameras\"\n        }\n        LineSeries {\n            id: observationsLengthsViewLineSerie\n            axisX: observationsLengthsvalueAxisX\n            axisY: observationsLengthsvalueAxisY\n            name: \"Current\"\n        }\n    }\n\n    Item {\n        id: observationsLengthsBtnContainer\n\n        Layout.fillWidth: true\n        anchors.bottom: observationsLengthsChart.bottom\n        anchors.bottomMargin: 35\n        anchors.left: observationsLengthsChart.left\n        anchors.leftMargin: observationsLengthsChart.width * 0.25\n\n        RowLayout {\n            ChartViewCheckBox {\n                id: allObservations\n                text: \"ALL\"\n                color: textColor\n                checkState: observationsLengthsLegend.buttonGroup.checkState\n                onClicked: {\n                    var _checked = checked;\n                    for (var i = 0; i < observationsLengthsChart.count; ++i) {\n                        observationsLengthsChart.series(i).visible = _checked\n                    }\n                }\n            }\n\n            ChartViewLegend {\n                id: observationsLengthsLegend\n                chartView: observationsLengthsChart\n            }\n        }\n    }\n\n    InteractiveChartView {\n        id: observationsScaleChart\n        width: parent.width * 0.5\n        height: parent.height * 0.5\n        anchors.left: parent.left\n        anchors.leftMargin: (parent.width) * 0.5\n        anchors.top: parent.top\n\n        legend.visible: false\n        title: \"Observations Scale\"\n\n        ValueAxis {\n            id: observationsScaleValueAxisX\n            titleText: \"Scale\"\n            min: 0\n            max: viewStat.observationsScaleMaxAxisX\n        }\n        ValueAxis {\n            id: observationsScaleValueAxisY\n            titleText: \"Number of Points\"\n            min: 0\n            max: viewStat.observationsScaleMaxAxisY\n        }\n        LineSeries {\n            id: observationsScaleFullLineSerie\n            axisX: observationsScaleValueAxisX\n            axisY: observationsScaleValueAxisY\n            name: \" Average on All Cameras\"\n        }\n        LineSeries {\n            id: observationsScaleViewLineSerie\n            axisX: observationsScaleValueAxisX\n            axisY: observationsScaleValueAxisY\n            name: \"Current\"\n        }\n    }\n\n    Item {\n        id: observationsScaleBtnContainer\n\n        Layout.fillWidth: true\n        anchors.bottom: observationsScaleChart.bottom\n        anchors.bottomMargin: 35\n        anchors.left: observationsScaleChart.left\n        anchors.leftMargin: observationsScaleChart.width * 0.15\n\n        RowLayout {\n            ChartViewCheckBox {\n                id: allObservationsScales\n                text: \"ALL\"\n                color: textColor\n                checkState: observationsScaleLegend.buttonGroup.checkState\n                onClicked: {\n                    var _checked = checked;\n                    for (var i = 0; i < observationsScaleChart.count; ++i) {\n                        observationsScaleChart.series(i).visible = _checked\n                    }\n                }\n            }\n\n            ChartViewLegend {\n                id: observationsScaleLegend\n                chartView: observationsScaleChart\n            }\n        }\n    }\n\n    // Stats from a view the sfmData\n    AliceVision.MViewStats {\n        id: viewStat\n        msfmData: (root.visible && root.msfmData && root.msfmData.status === AliceVision.MSfMData.Ready) ? root.msfmData : null\n        viewId: root.viewId\n        onViewStatsChanged: {\n            fillResidualFullSerie(residualFullLineSerie)\n            fillResidualViewSerie(residualViewLineSerie)\n            fillObservationsLengthsFullSerie(observationsLengthsFullLineSerie)\n            fillObservationsLengthsViewSerie(observationsLengthsViewLineSerie)\n            fillObservationsScaleFullSerie(observationsScaleFullLineSerie)\n            fillObservationsScaleViewSerie(observationsScaleViewLineSerie)\n        }\n    }\n}\n"
  },
  {
    "path": "meshroom/ui/qml/Viewer/TestAliceVisionPlugin.qml",
    "content": "import QtQuick\n\nimport AliceVision 1.0\n\n/**\n * To evaluate if the QtAliceVision plugin is available.\n */\n\nItem {\n    id: root\n}\n"
  },
  {
    "path": "meshroom/ui/qml/Viewer/TextViewer.qml",
    "content": "import QtQuick\nimport QtQuick.Controls\nimport QtQuick.Layouts\n\nimport Controls 1.0\nimport MaterialIcons 2.2\nimport Utils 1.0\n\n/**\n * TextViewer displays the content of a text file (e.g. .txt, .json, .log, .csv).\n */\n\nFocusScope {\n    id: root\n\n    clip: true\n\n    property url source: \"\"\n\n    Rectangle {\n        anchors.fill: parent\n        color: Qt.darker(palette.base, 1.1)\n\n        ColumnLayout {\n            anchors.fill: parent\n            spacing: 0\n\n            // File path toolbar\n            RowLayout {\n                id: filePathBar\n                Layout.fillWidth: true\n                spacing: 4\n                visible: source.toString() !== \"\"\n\n                TextField {\n                    id: filePathTextField\n                    Layout.fillWidth: true\n                    text: Filepath.urlToString(root.source)\n                    font.pointSize: 8\n                    readOnly: true\n                    selectByMouse: true\n                    background: Item {}\n                    padding: 4\n                }\n\n                MaterialToolButton {\n                    text: MaterialIcons.content_copy\n                    ToolTip.text: \"Copy File Path to Clipboard\"\n                    font.pointSize: 10\n                    padding: 4\n                    onClicked: {\n                        filePathTextField.selectAll()\n                        filePathTextField.copy()\n                        filePathTextField.deselect()\n                    }\n                }\n            }\n\n            Rectangle {\n                Layout.fillWidth: true\n                height: 1\n                color: palette.mid\n                visible: filePathBar.visible\n            }\n\n            // Text content area\n            TextFileViewer {\n                Layout.fillWidth: true\n                Layout.fillHeight: true\n                source: root.source\n            }\n        }\n    }\n}\n"
  },
  {
    "path": "meshroom/ui/qml/Viewer/Viewer2D.qml",
    "content": "import QtQuick\nimport QtQuick.Controls\nimport QtQuick.Layouts\n\nimport Controls 1.0\nimport MaterialIcons 2.2\nimport Utils 1.0\n\nFocusScope {\n    id: root\n\n    clip: true\n\n    property var displayedNode: null\n    property var displayedAttr: (displayedNode && outputAttribute.name != \"gallery\") ? displayedNode.attributes.get(outputAttribute.name) : null\n    property var displayedAttrValue: displayedAttr ? displayedAttr.value : \"\"\n\n    property bool useExternal: false\n    property url sourceExternal\n\n    property url source\n    property var viewIn3D\n\n    property Component floatViewerComp: Qt.createComponent(\"FloatImage.qml\")\n    property Component panoramaViewerComp: Qt.createComponent(\"PanoramaViewer.qml\")\n    property var useFloatImageViewer: displayHDR.checked\n    property alias useLensDistortionViewer: displayLensDistortionViewer.checked\n    property alias usePanoramaViewer: displayPanoramaViewer.checked\n\n    property var activeNodeFisheye: _currentScene ? _currentScene.activeNodes.get(\"PanoramaInit\").node : null\n    property bool cropFisheye : activeNodeFisheye ? activeNodeFisheye.attribute(\"useFisheye\").value : false\n    property bool enable8bitViewer: enable8bitViewerAction.checked\n    property bool enableSequencePlayer: enableSequencePlayerAction.checked\n\n    readonly property alias sync3DSelected: sequencePlayer.sync3DSelected\n    property var sequence: []\n    property alias currentFrame: sequencePlayer.frameId\n    property alias frameRange: sequencePlayer.frameRange\n\n    property bool fittedOnce: false\n    property int previousWidth: -1\n    property int previousHeight: -1\n    property int previousOrientationTag: 1\n\n    // State for double-click zoom toggle\n    property real previousZoomScale: -1.0\n    property real previousZoomX: 0.0\n    property real previousZoomY: 0.0\n\n    QtObject {\n        id: m\n        property variant viewpointMetadata: {\n            // Metadata from viewpoint attribute\n            // Read from the scene object\n            if (_currentScene) {\n                let vp = getViewpoint(_currentScene.selectedViewId)\n                if (vp) {\n                    return JSON.parse(vp.childAttribute(\"metadata\").value)\n                }\n            }\n            return {}\n        }\n        property variant imgMetadata: {\n            // Metadata from FloatImage viewer\n            // Directly read from the image file on disk\n            if (floatImageViewerLoader.active && floatImageViewerLoader.item) {\n                return floatImageViewerLoader.item.metadata\n            }\n            // Metadata from PhongImageViewer\n            // Directly read from the image file on disk\n            if (phongImageViewerLoader.active) {\n                return phongImageViewerLoader.item.metadata\n            }\n            // Use viewpoint metadata for the special case of the 8-bit viewer\n            if (qtImageViewerLoader.active) {\n                return viewpointMetadata\n            }\n            return {}\n        }\n    }\n\n    Loader {\n        id: aliceVisionPluginLoader\n        active: true\n        source: \"TestAliceVisionPlugin.qml\"\n    }\n\n    readonly property bool aliceVisionPluginAvailable: aliceVisionPluginLoader.status === Component.Ready\n\n    Component.onCompleted: {\n        if (!aliceVisionPluginAvailable) {\n            console.warn(\"Missing plugin qtAliceVision.\")\n            displayHDR.checked = false\n        }\n    }\n\n    property string loadingModules: {\n        if (!imgContainer.image)\n            return \"\"\n        var res = \"\"\n        if (imgContainer.image.imageStatus === Image.Loading) {\n            res += \" Image\"\n        }\n        if (mfeaturesLoader.status === Loader.Ready) {\n            if (mfeaturesLoader.item && mfeaturesLoader.item.status === MFeatures.Loading)\n                res += \" Features\"\n        }\n        if (mtracksLoader.status === Loader.Ready) {\n            if (mtracksLoader.item && mtracksLoader.item.status === MTracks.Loading)\n                res += \" Tracks\"\n        }\n        if (msfmDataLoader.status === Loader.Ready) {\n            if (msfmDataLoader.item && msfmDataLoader.item.status === MSfMData.Loading)\n                res += \" SfMData\"\n        }\n        return res\n    }\n\n    function clear() {\n        source = \"\"\n    }\n\n    // Slots\n    Keys.onPressed: function(event) {\n        if (event.key === Qt.Key_F) {\n            root.fit()\n            event.accepted = true\n        } else if (event.key === Qt.Key_1) {\n            root.zoomPixelSize()\n            event.accepted = true\n        }\n    }\n\n    // Mouse area\n    MouseArea {\n        anchors.fill: parent\n        property double factor: 1.2\n        acceptedButtons: Qt.LeftButton | Qt.RightButton | Qt.MiddleButton\n\n        onPressed: function(mouse) {\n            imgContainer.forceActiveFocus()\n            if (mouse.button & Qt.MiddleButton || (mouse.button & Qt.LeftButton && mouse.modifiers & Qt.ShiftModifier))\n                drag.target = imgContainer  // Start drag\n        }\n\n        onReleased: function(mouse) {\n            drag.target = undefined  // Stop drag\n            if (mouse.button & Qt.RightButton) {\n                var menu = contextMenu.createObject(root)\n                menu.x = mouse.x\n                menu.y = mouse.y\n                menu.mousePos = Qt.point(mouse.x, mouse.y)\n                menu.open()\n            }\n        }\n\n        onDoubleClicked: function(mouse) {\n            if (Math.abs(imgContainer.scale - 1.0) > 0.001) {\n                // Not at 100%: save current state and zoom to 100% keeping cursor position\n                root.previousZoomScale = imgContainer.scale\n                root.previousZoomX = imgContainer.x\n                root.previousZoomY = imgContainer.y\n                zoomPixelSize(mouse.x, mouse.y)\n            } else if (root.previousZoomScale > 0) {\n                // Already at 100%: restore previous zoom state\n                imgContainer.scale = root.previousZoomScale\n                imgContainer.x = root.previousZoomX\n                imgContainer.y = root.previousZoomY\n                root.previousZoomScale = -1.0\n            }\n        }\n\n        onWheel: function(wheel) {\n            var zoomFactor = wheel.angleDelta.y > 0 ? factor : 1 / factor\n\n            if (Math.min(imgContainer.width, imgContainer.image.height) * imgContainer.scale * zoomFactor < 10)\n                return\n            var point = mapToItem(imgContainer, wheel.x, wheel.y)\n            imgContainer.x += (1 - zoomFactor) * point.x * imgContainer.scale\n            imgContainer.y += (1 - zoomFactor) * point.y * imgContainer.scale\n            imgContainer.scale *= zoomFactor\n        }\n    }\n\n    onEnable8bitViewerChanged: {\n        if (!enable8bitViewer) {\n            displayHDR.checked = true\n        }\n    }\n\n    // Functions\n    function fit() {\n        // Make sure the image is ready for use\n        if (!imgContainer.image) {\n            return false\n        }\n\n        // For Exif orientation tags 5 to 8, a 90 degrees rotation is applied\n        // therefore image dimensions must be inverted\n        let dimensionsInverted = [\"5\", \"6\", \"7\", \"8\"].includes(imgContainer.orientationTag)\n        let orientedWidth = dimensionsInverted ? imgContainer.image.height : imgContainer.image.width\n        let orientedHeight = dimensionsInverted ? imgContainer.image.width : imgContainer.image.height\n\n        // Fit oriented image\n        imgContainer.scale = Math.min(imgLayout.width / orientedWidth, root.height / orientedHeight)\n        imgContainer.x = Math.max((imgLayout.width - orientedWidth * imgContainer.scale) * 0.5, 0)\n        imgContainer.y = Math.max((imgLayout.height - orientedHeight * imgContainer.scale) * 0.5, 0)\n\n        // Correct position when image dimensions are inverted\n        // so that container center corresponds to image center\n        imgContainer.x += (orientedWidth - imgContainer.image.width) * 0.5 * imgContainer.scale\n        imgContainer.y += (orientedHeight - imgContainer.image.height) * 0.5 * imgContainer.scale\n\n        return true\n    }\n\n    function zoomPixelSize(mouseX, mouseY) {\n        var newScale = 1.0\n        if (mouseX !== undefined && mouseY !== undefined) {\n            var point = mapToItem(imgContainer, mouseX, mouseY)\n            imgContainer.x += (imgContainer.scale - newScale) * point.x\n            imgContainer.y += (imgContainer.scale - newScale) * point.y\n        } else {\n            imgContainer.x = Math.max((imgLayout.width - imgContainer.width * newScale) * 0.5, 0)\n            imgContainer.y = Math.max((imgLayout.height - imgContainer.height * newScale) * 0.5, 0)\n        }\n        imgContainer.scale = newScale\n    }\n\n    function tryLoadNode(node) {\n        useExternal = false\n\n        // Safety check\n        if (!node) {\n            return false\n        }\n\n        // Node must be computed or at least running\n        if (node.isComputableType && !node.isPartiallyFinished()) {\n            return false\n        }\n\n        // Node must have at least one output attribute with the image semantic\n        if (!node.hasImageOutput && !node.hasSequenceOutput) {\n            return false\n        }\n\n        displayedNode = node\n        return true\n    }\n\n    function loadExternal(path) {\n        useExternal = true\n        sourceExternal = path\n        displayedNode = null\n    }\n\n    function getViewpoint(viewId) {\n        // Get viewpoint from cameraInit with matching id\n        // This requires to loop over all viewpoints\n        for (var i = 0; i < _currentScene.viewpoints.count; i++) {\n            var vp = _currentScene.viewpoints.at(i)\n            if (vp.childAttribute(\"viewId\").value == viewId) {\n                return vp\n            }\n        }\n\n        return undefined\n    }\n\n    function getImageFile() {\n        if (useExternal) {\n            // Entry point for getting the image file from an external URL\n            return sourceExternal\n        }\n\n        if (_currentScene && (!displayedNode || outputAttribute.name == \"gallery\")) {\n            // Entry point for getting the image file from the gallery\n            let vp = getViewpoint(_currentScene.pickedViewId)\n            let path = vp ? vp.childAttribute(\"path\").value : \"\"\n            _currentScene.currentViewPath = path\n            return Filepath.stringToUrl(path)\n        }\n\n        if (_currentScene && displayedNode && displayedNode.hasSequenceOutput && displayedAttr &&\n            (displayedAttr.desc.semantic === \"imageList\" || displayedAttr.desc.semantic === \"sequence\")) {\n            // Entry point for getting the image file from a sequence defined by an output attribute\n            var path = sequence[currentFrame - frameRange.min]\n            _currentScene.currentViewPath = path\n            return Filepath.stringToUrl(path)\n        }\n\n        if (_currentScene) {\n            // Entry point for getting the image file from an output attribute and associated to the current viewpoint\n            let vp = getViewpoint(_currentScene.pickedViewId)\n            let path = displayedAttr ? displayedAttr.value : \"\"\n            let resolved = vp ? Filepath.resolve(path, vp) : path\n            _currentScene.currentViewPath = resolved\n            return Filepath.stringToUrl(resolved)\n        }\n\n        return undefined\n    }\n\n    function buildOrderedSequence(pathTemplate) {\n        // Resolve the path template on the sequence of viewpoints\n        // ordered by path\n        let outputFiles = []\n\n        if (displayedNode && displayedNode.hasSequenceOutput && displayedAttr) {\n\n            // Reset current frame to 0 if it is imageList but not sequence\n            if (displayedAttr.desc.semantic === \"imageList\") {\n                let includesSeqMissingFiles = false  // list only the existing files\n                let [_, filesSeqs] = Filepath.resolveSequence(pathTemplate, includesSeqMissingFiles)\n                // Concat in one array all sequences in resolved\n                outputFiles = [].concat.apply([], filesSeqs)\n                let newFrameRange = [0, outputFiles.length - 1]\n\n                if(frameRange.min != newFrameRange[0] || frameRange.max != newFrameRange[1]) {\n                    frameRange.min = newFrameRange[0]\n                    frameRange.max = newFrameRange[1]\n                    // Change the current frame, only if the frame range is different\n                    currentFrame = frameRange.min\n                }\n\n                enableSequencePlayerAction.checked = true\n            }\n\n            if (displayedAttr.desc.semantic === \"sequence\") {\n                let includesSeqMissingFiles = true\n                let [frameRanges, filesSeqs] = Filepath.resolveSequence(pathTemplate, includesSeqMissingFiles)\n                let newFrameRange = [0, 0]\n                if (filesSeqs.length > 0) {\n                    // If there is one or several sequences, take the first one\n                    outputFiles = filesSeqs[0]\n                    newFrameRange = frameRanges[0]\n\n                    if(frameRange.min != newFrameRange[0] || frameRange.max != newFrameRange[1]) {\n                        frameRange.min = newFrameRange[0]\n                        frameRange.max = newFrameRange[1]\n                        // Change the current frame, only if the frame range is different\n                        currentFrame = frameRange.min\n                    }\n                }\n\n\n                enableSequencePlayerAction.checked = true\n            }\n        } else {\n            let objs = []\n            for (let i = 0; i < _currentScene.viewpoints.count; i++) {\n                objs.push(_currentScene.viewpoints.at(i))\n            }\n            objs.sort((a, b) => { return a.childAttribute(\"path\").value < b.childAttribute(\"path\").value ? -1 : 1; })\n            \n            for (let i = 0; i < objs.length; i++) {\n                outputFiles.push(Filepath.resolve(pathTemplate, objs[i]))\n            }\n\n            frameRange.min = 0\n            frameRange.max = outputFiles.length - 1\n        }\n        return outputFiles\n    }\n\n    function getSequence() {\n        // Entry point for getting the current image sequence\n        if (useExternal) {\n            return []\n        }\n\n        if (_currentScene && (!displayedNode || outputAttribute.name == \"gallery\")) {\n            return buildOrderedSequence(\"<PATH>\")\n        }\n\n        if (_currentScene) {\n            return buildOrderedSequence(displayedAttrValue)\n        }\n\n        return []\n    }\n\n    function setAttributeName(attrName) {\n        outputAttribute.setName(attrName)        \n    }\n\n    onDisplayedNodeChanged: {\n        if (!displayedNode) {\n            root.source = \"\"\n        }\n\n        // Update output attribute names\n        var names = []\n        if (displayedNode) {\n            // Store attr name for output attributes that represent images\n            for (var i = 0; i < displayedNode.attributes.count; i++) {\n                var attr = displayedNode.attributes.at(i)\n                if (attr.isOutput && (attr.desc.semantic === \"image\" || attr.desc.semantic === \"sequence\" ||\n                    attr.desc.semantic === \"imageList\") && attr.enabled) {\n                    names.push(attr.name)\n                }\n            }\n        }\n\n        if (!displayedNode || displayedNode.isComputableType)\n            names.push(\"gallery\")\n\n        outputAttribute.names = names\n        outputAttribute.lastOutputName = names.find(n => n !== \"gallery\") || \"\"\n    }\n\n    onDisplayedAttrValueChanged: {\n        if (displayedNode && !displayedNode.hasSequenceOutput) {\n            root.source = getImageFile()\n            root.sequence = []\n        } else {\n            root.source = \"\"\n            root.sequence = getSequence()\n            if (currentFrame > frameRange.max)\n                currentFrame = frameRange.min\n        }\n    }\n\n    onDisplayedAttrChanged: {\n        _currentScene.displayedAttr2D = displayedAttr\n    }\n\n    Connections {\n        target: _currentScene\n        function onSelectedViewIdChanged() {\n            root.source = getImageFile()\n            if (useExternal)\n                useExternal = false\n        }\n    }\n\n    Connections {\n        target: displayedNode\n        function onOutputAttrChanged() {\n            tryLoadNode(displayedNode)\n        }\n    }\n\n    // context menu\n    property Component contextMenu: Menu {\n        property point mousePos: Qt.point(0, 0)\n        MenuItem {\n            text: \"Fit\"\n            onTriggered: fit()\n        }\n        MenuItem {\n            text: \"Zoom 100%\"\n            onTriggered: {\n                zoomPixelSize(mousePos.x, mousePos.y)\n            }\n        }\n    }\n\n    ColumnLayout {\n        anchors.fill: parent\n\n        HdrImageToolbar {\n            id: hdrImageToolbar\n            anchors.margins: 0\n            visible: displayImageToolBarAction.checked && displayImageToolBarAction.enabled\n            Layout.fillWidth: true\n            onVisibleChanged: {\n                resetDefaultValues()\n            }\n            colorPickerVisible: {\n                return !displayPanoramaViewer.checked && !displayPhongLighting.checked\n            }\n\n            colorRGBA: {\n                                       \n                if (!floatImageViewerLoader.item ||\n                    floatImageViewerLoader.item.imageStatus !== Image.Ready) {\n                    return null\n                }\n                \n                /// Get the pixel color value at mouse position (when mouse hover the image)\n                if (mousePosition && floatImageViewerLoader.item.containsMouse === true) {\n                    return floatImageViewerLoader.item.pixelValueAt( mousePosition.x, mousePosition.y )\n                } \n\n                if ( !Number.isInteger(userDefinedXPixel) || !Number.isInteger(userDefinedYPixel) ) {\n                    return null\n                }\n\n                // Get the pixel color value from text field value (let the possibility to user to set the x,y from ui)\n                return floatImageViewerLoader.item.pixelValueAt( parseInt(userDefinedXPixel) , parseInt(userDefinedYPixel) )\n                \n            }\n\n            mousePosition: (floatImageViewerLoader.item && floatImageViewerLoader.item.containsMouse ? {\n                    x: Math.floor(floatImageViewerLoader.item.mouseX), \n                    y: Math.floor(floatImageViewerLoader.item.mouseY)\n                } : null)\n        }\n\n        LensDistortionToolbar {\n            id: lensDistortionImageToolbar\n            anchors.margins: 0\n            visible: displayLensDistortionToolBarAction.checked && displayLensDistortionToolBarAction.enabled\n            Layout.fillWidth: true\n        }\n\n        PanoramaToolbar {\n            id: panoramaViewerToolbar\n            anchors.margins: 0\n            visible: displayPanoramaToolBarAction.checked && displayPanoramaToolBarAction.enabled\n            Layout.fillWidth: true\n        }\n\n        // Image\n        Item {\n            id: imgLayout\n            Layout.fillWidth: true\n            Layout.fillHeight: true\n            clip: true\n            Image {\n                id: alphaBackground\n                anchors.fill: parent\n                visible: displayAlphaBackground.checked\n                fillMode: Image.Tile\n                horizontalAlignment: Image.AlignLeft\n                verticalAlignment: Image.AlignTop\n                source: \"../../img/checkerboard_light.png\"\n                scale: 4\n                smooth: false\n            }\n\n            Item {\n                id: imgContainer\n                transformOrigin: Item.TopLeft\n                property var orientationTag: m.imgMetadata ? m.imgMetadata[\"Orientation\"] : 0\n\n                // qtAliceVision Image Viewer\n                ExifOrientedViewer {\n                    id: floatImageViewerLoader\n                    active: root.aliceVisionPluginAvailable && (root.useFloatImageViewer || root.useLensDistortionViewer) && !panoramaViewerLoader.active && !phongImageViewerLoader.active\n                    visible: (floatImageViewerLoader.status === Loader.Ready) && active\n                    anchors.centerIn: parent\n                    orientationTag: imgContainer.orientationTag\n                    xOrigin: imgContainer.width / 2\n                    yOrigin: imgContainer.height / 2\n                    property real resizeRatio: imgContainer.scale\n\n                    function sizeChanged() {\n                        /* Image size is not updated through a single signal with the floatImage viewer, unlike\n                         * the simple QML image viewer: instead of updating straight away the width and height to x and\n                         * y, the emitted signals look like:\n                         * - width = -1, height = -1\n                         * - width = x, height = -1\n                         * - width = x, height = y\n                         * We want to do the auto-fit on the first display of an image from the group, and then keep its\n                         * scale when displaying another image from the group, so we need to know if an image in the\n                         * group has already been auto-fitted. If we change the group of images (when another project is\n                         * opened, for example, and the images have a different size), then another auto-fit needs to be\n                         * performed */\n\n                        var sizeValid = (width > 0) && (height > 0)\n                        var layoutValid = (root.width > 50) && (root.height > 50)\n                        var sizeChanged = (root.previousWidth != width) || (root.previousHeight != height)\n\n                        if ((!root.fittedOnce && imgContainer.image && sizeValid && layoutValid) ||\n                            (root.fittedOnce && sizeChanged && sizeValid && layoutValid)) {\n                            var ret = fit()\n                            if (!ret)\n                                return\n                            root.fittedOnce = true\n                            root.previousWidth = width\n                            root.previousHeight = height\n                            if (orientationTag != undefined)\n                                root.previousOrientationTag = orientationTag\n                        }\n                    }\n\n                    onWidthChanged : {\n                        floatImageViewerLoader.sizeChanged();\n                    }\n\n                    Connections {\n                        target: root\n                        function onWidthChanged() {\n                            floatImageViewerLoader.sizeChanged()\n                        }\n\n                        function onHeightChanged() {\n                            floatImageViewerLoader.sizeChanged()\n                        }\n                    }\n\n                    onOrientationTagChanged: {\n                        /* For images of the same width and height but with different orientations, the auto-fit\n                         * will not be triggered by the \"widthChanged()\" signal, so it needs to be triggered upon\n                         * either a change in the image's size or in its orientation. */\n                         if (orientationTag != undefined && root.previousOrientationTag != orientationTag) {\n                            var ret = fit()\n                            if (!ret)\n                                return\n                            root.previousWidth = width\n                            root.previousHeight = height\n                            root.previousOrientationTag = orientationTag\n                         }\n                    }\n\n                    onActiveChanged: {\n                        if (active) {\n                            // Instantiate and initialize a FloatImage component dynamically using Loader.setSource\n                            // Note: It does not work to use previously created component, so we re-create it with setSource.\n                            floatImageViewerLoader.setSource(\"FloatImage.qml\", {\n                                \"source\":  Qt.binding(function() { return getImageFile() }),\n                                \"gamma\": Qt.binding(function() { return hdrImageToolbar.gammaValue }),\n                                \"gain\": Qt.binding(function() { return hdrImageToolbar.gainValue }),\n                                \"channelModeString\": Qt.binding(function() { return hdrImageToolbar.channelModeValue }),\n                                \"isPrincipalPointsDisplayed\": Qt.binding(function() { return lensDistortionImageToolbar.displayPrincipalPoint }),\n                                \"surface.displayGrid\":  Qt.binding(function() { return lensDistortionImageToolbar.visible && lensDistortionImageToolbar.displayGrid }),\n                                \"surface.gridOpacity\": Qt.binding(function() { return lensDistortionImageToolbar.opacityValue }),\n                                \"surface.gridColor\": Qt.binding(function() { return lensDistortionImageToolbar.color }),\n                                \"surface.subdivisions\": Qt.binding(function() { return root.useFloatImageViewer ? 1 : lensDistortionImageToolbar.subdivisionsValue }),\n                                \"viewerTypeString\": Qt.binding(function() { return displayLensDistortionViewer.checked ? \"distortion\" : \"hdr\" }),\n                                \"surface.msfmData\": Qt.binding(function() { return (msfmDataLoader.status === Loader.Ready && msfmDataLoader.item != null && msfmDataLoader.item.status === 2) ? msfmDataLoader.item : null }),\n                                \"canBeHovered\": false,\n                                \"idView\": Qt.binding(function() { return ((root.displayedNode && !root.displayedNode.hasSequenceOutput && _currentScene) ? _currentScene.selectedViewId : -1) }),\n                                \"cropFisheye\": false,\n                                \"sequence\": Qt.binding(function() { return ((root.enableSequencePlayer && (_currentScene || (root.displayedNode && root.displayedNode.hasSequenceOutput))) ? getSequence() : []) }),\n                                \"resizeRatio\": Qt.binding(function() { return floatImageViewerLoader.resizeRatio }),\n                                \"useSequence\": Qt.binding(function() { \n                                    return (root.enableSequencePlayer && !useExternal && (_currentScene || (root.displayedNode && root.displayedNode.hasSequenceOutput && (displayedAttr.desc.semantic === \"imageList\" || displayedAttr.desc.semantic === \"sequence\"))))\n                                }),\n                                \"fetchingSequence\": Qt.binding(function() { return sequencePlayer.loading }),\n                                \"memoryLimit\": Qt.binding(function() { return sequencePlayer.settings_SequencePlayer.maxCacheMemory }),\n                                })\n                          } else {\n                                // Forcing the unload (instead of using Component.onCompleted to load it once and for all) is necessary since Qt 5.14\n                                floatImageViewerLoader.setSource(\"\", {})\n                                fittedOnce = false\n                          }\n                    }\n                }\n\n                // qtAliceVision Panorama Viewer\n                Loader {\n                    id: panoramaViewerLoader\n                    active: root.aliceVisionPluginAvailable && root.usePanoramaViewer &&\n                            _currentScene.activeNodes.get('sfm').node\n                    visible: (panoramaViewerLoader.status === Loader.Ready) && active\n                    anchors.centerIn: parent\n\n                    onActiveChanged: {\n                        if (active) {\n                            setSource(\"PanoramaViewer.qml\", {\n                                \"subdivisionsPano\": Qt.binding(function() { return panoramaViewerToolbar.subdivisionsValue }),\n                                \"cropFisheyePano\": Qt.binding(function() { return root.cropFisheye }),\n                                \"downscale\": Qt.binding(function() { return panoramaViewerToolbar.downscaleValue }),\n                                \"isEditable\": Qt.binding(function() { return panoramaViewerToolbar.enableEdit }),\n                                \"isHighlightable\": Qt.binding(function() { return panoramaViewerToolbar.enableHover }),\n                                \"displayGridPano\": Qt.binding(function() { return panoramaViewerToolbar.displayGrid }),\n                                \"mouseMultiplier\": Qt.binding(function() { return panoramaViewerToolbar.mouseSpeed }),\n                                \"msfmData\": Qt.binding(function() { return (msfmDataLoader && msfmDataLoader.item && msfmDataLoader.status === Loader.Ready\n                                                                           && msfmDataLoader.item.status === 2) ? msfmDataLoader.item : null }),\n                            })\n                        } else {\n                            // Forcing the unload (instead of using Component.onCompleted to load it once and for all) is necessary since Qt 5.14\n                            setSource(\"\", {})\n                            displayPanoramaViewer.checked = false\n                        }\n                    }\n                }\n\n                // qtAliceVision Phong Image Viewer\n                ExifOrientedViewer {\n                    id: phongImageViewerLoader\n                    active: root.aliceVisionPluginAvailable && displayPhongLighting.enabled && displayPhongLighting.checked\n                    visible: (phongImageViewerLoader.status === Loader.Ready) && active\n                    anchors.centerIn: parent\n                    orientationTag: imgContainer.orientationTag\n                    xOrigin: imgContainer.width / 2\n                    yOrigin: imgContainer.height / 2\n\n                    property var selectedNode: _currentScene ? _currentScene.selectedNode : null\n                    property var vp: _currentScene ? getViewpoint(_currentScene.selectedViewId) : null\n                    property url sourcePath: getAlbedoFile()\n                    property url normalPath: getNormalFile()\n                    property bool fittedOnce: false\n                    property int previousWidth: 0\n                    property int previousHeight: 0\n                    property int previousOrientationTag: 1\n\n                    function getAlbedoFile() {\n                        // get the image file from an external URL\n                        if (useExternal) {\n                            var externalFile = Filepath.urlToString(sourceExternal)\n                            if(externalFile.includes(\"_normals\"))\n                                return Filepath.stringToUrl(externalFile.replace(\"_normals\", \"_albedo\"))\n                            return sourceExternal\n                        }\n                        \n                        // get the image file from selected node albedo attribute\n                        if(vp && selectedNode && selectedNode.hasAttribute(\"albedo\"))\n                            return Filepath.stringToUrl(Filepath.resolve(selectedNode.attribute(\"albedo\").value, vp))\n\n                        // no valid image file, return empty url\n                        return \"\"\n                    }\n\n                    function getNormalFile() {\n                        // get the image file from an external URL\n                        if (useExternal) {\n                            var externalFile = Filepath.urlToString(sourceExternal)\n                            if(externalFile.includes(\"_normals\"))\n                                return sourceExternal\n                            if(externalFile.includes(\"_albedo\"))\n                                return Filepath.stringToUrl(externalFile.replace(\"_albedo\", \"_normals\"))\n                            return \"\" // invalid external file\n                        }\n\n                        // get the image file from selected node normals attribute\n                        if(vp && selectedNode && selectedNode.hasAttribute(\"normals\"))\n                            return Filepath.stringToUrl(Filepath.resolve(selectedNode.attribute(\"normals\").value, vp))\n                            \n                        // no valid image file, return empty url\n                        return \"\"\n                    }\n\n                    onWidthChanged: {\n                        /* We want to do the auto-fit on the first display of an image from the group, and then keep its\n                         * scale when displaying another image from the group, so we need to know if an image in the\n                         * group has already been auto-fitted. If we change the group of images (when another project is\n                         * opened, for example, and the images have a different size), then another auto-fit needs to be\n                         * performed */\n                        if ((!fittedOnce && imgContainer.image && imgContainer.image.width > 0) ||\n                            (fittedOnce && ((width > 1 && previousWidth != width) || (height > 1 && previousHeight != height)))) {\n                            var ret = fit()\n                            if (!ret)\n                                return\n                            fittedOnce = true\n                            previousWidth = width\n                            previousHeight = height\n                            if (orientationTag != undefined)\n                                previousOrientationTag = orientationTag\n                        }\n                    }\n\n                    onOrientationTagChanged: {\n                        /* For images of the same width and height but with different orientations, the auto-fit\n                         * will not be triggered by the \"widthChanged()\" signal, so it needs to be triggered upon\n                         * either a change in the image's size or in its orientation. */\n                        if (orientationTag != undefined && previousOrientationTag != orientationTag) {\n                            var ret = fit()\n                            if (!ret)\n                                return\n                            fittedOnce = true\n                            previousWidth = width\n                            previousHeight = height\n                            previousOrientationTag = orientationTag\n                        }\n                    }\n\n                    onActiveChanged: {\n                        if (active) {\n                            /* Instantiate and initialize a PhongImageViewer component dynamically using Loader.setSource\n                             * Note: It does not work to use previously created component, so we re-create it with setSource. */\n                            setSource(\"PhongImageViewer.qml\", {\n                                'sourcePath':  Qt.binding(function() { return sourcePath }),\n                                'normalPath':  Qt.binding(function() { return normalPath }),\n                                'gamma': Qt.binding(function() { return hdrImageToolbar.gammaValue }),\n                                'gain': Qt.binding(function() { return hdrImageToolbar.gainValue }),\n                                'channelModeString': Qt.binding(function() { return hdrImageToolbar.channelModeValue }),\n                                'baseColor': Qt.binding(function() { return phongImageViewerToolbarLoader.item !== null ? phongImageViewerToolbarLoader.item.baseColorValue : \"#ffffff\" }),\n                                'textureOpacity': Qt.binding(function() { return phongImageViewerToolbarLoader.item !== null ? phongImageViewerToolbarLoader.item.textureOpacityValue : 0.0}),\n                                'ka': Qt.binding(function() { return phongImageViewerToolbarLoader.item !== null ? phongImageViewerToolbarLoader.item.kaValue : 0.0 }),\n                                'kd': Qt.binding(function() { return phongImageViewerToolbarLoader.item !== null ? phongImageViewerToolbarLoader.item.kdValue : 0.0 }),\n                                'ks': Qt.binding(function() { return phongImageViewerToolbarLoader.item !== null ? phongImageViewerToolbarLoader.item.ksValue : 0.0 }),\n                                'shininess': Qt.binding(function() { return phongImageViewerToolbarLoader.item !== null ? phongImageViewerToolbarLoader.item.shininessValue : 0.0 }),\n                                'lightYaw': Qt.binding(function() { return directionalLightPaneLoader.item !== null ? -directionalLightPaneLoader.item.lightYawValue : 0.0 }), // left handed coordinate system\n                                'lightPitch': Qt.binding(function() { return directionalLightPaneLoader.item !== null ? directionalLightPaneLoader.item.lightPitchValue : 0.0 }),\n                            })\n                        } else {\n                            // Forcing the unload (instead of using Component.onCompleted to load it once and for all) is necessary since Qt 5.14\n                            setSource(\"\", {})\n                            fittedOnce = false\n                        }\n                    }\n                }\n\n                // Simple QML Image Viewer (using Qt or qtAliceVisionImageIO to load images)\n                ExifOrientedViewer {\n                    id: qtImageViewerLoader\n                    active: !floatImageViewerLoader.active && !panoramaViewerLoader.active && !phongImageViewerLoader.active\n                    anchors.centerIn: parent\n                    orientationTag: imgContainer.orientationTag\n                    xOrigin: imgContainer.width / 2\n                    yOrigin: imgContainer.height / 2\n                    sourceComponent: Image {\n                        id: qtImageViewer\n                        asynchronous: true\n                        smooth: false\n                        fillMode: Image.PreserveAspectFit\n                        onWidthChanged: if (status==Image.Ready) fit()\n                        source: getImageFile()\n                        onStatusChanged: {\n                            // Update cache source when image is loaded\n                            imageStatus = status\n                            if (status === Image.Ready)\n                                qtImageViewerCache.source = source\n                        }\n\n                        property var imageStatus: Image.Ready\n\n                        // Image cache of the last loaded image\n                        // Only visible when the main one is loading, to maintain a displayed image for smoother transitions\n                        Image {\n                            id: qtImageViewerCache\n\n                            anchors.fill: parent\n                            asynchronous: true\n                            smooth: parent.smooth\n                            fillMode: parent.fillMode\n\n                            visible: qtImageViewer.status === Image.Loading\n                        }\n                    }\n                }\n\n                property var image: {\n                    if (floatImageViewerLoader.active)\n                        floatImageViewerLoader.item\n                    else if (panoramaViewerLoader.active)\n                        panoramaViewerLoader.item\n                    else if (phongImageViewerLoader.active)\n                        phongImageViewerLoader.item\n                    else\n                        qtImageViewerLoader.item\n                }\n                width: image ? (image.width > 0 ? image.width : 1) : 1\n                height: image ? (image.height > 0 ? image.height : 1) : 1\n                scale: 1.0\n\n                // FeatureViewer: display view extracted feature points\n                // Note: requires QtAliceVision plugin - use a Loader to evaluate plugin availability at runtime\n                ExifOrientedViewer {\n                    id: featuresViewerLoader\n                    active: displayFeatures.checked && !useExternal\n                    property var activeNode: _currentScene ? _currentScene.activeNodes.get(\"featureProvider\").node : null\n                    width: imgContainer.width\n                    height: imgContainer.height\n                    anchors.centerIn: parent\n                    orientationTag: imgContainer.orientationTag\n                    xOrigin: imgContainer.width / 2\n                    yOrigin: imgContainer.height / 2\n\n                    onActiveChanged: {\n                        if (active) {\n                            // Instantiate and initialize a FeaturesViewer component dynamically using Loader.setSource\n                            setSource(\"FeaturesViewer.qml\", {\n                                \"model\": Qt.binding(function() { return activeNode ? activeNode.attribute(\"describerTypes\").value : \"\" }),\n                                \"currentViewId\": Qt.binding(function() { return _currentScene.selectedViewId }),\n                                \"features\": Qt.binding(function() { return mfeaturesLoader.status === Loader.Ready ? mfeaturesLoader.item : null }),\n                                \"tracks\": Qt.binding(function() { return mtracksLoader.status === Loader.Ready ? mtracksLoader.item : null }),\n                                \"sfmData\": Qt.binding(function() { return msfmDataLoader.status === Loader.Ready ? msfmDataLoader.item : null }),\n                                \"syncFeaturesSelected\": Qt.binding(function() { return sequencePlayer.syncFeaturesSelected }),\n                            })\n                        } else {\n                            // Forcing the unload (instead of using Component.onCompleted to load it once and for all) is necessary since Qt 5.14\n                            setSource(\"\", {})\n                        }\n                    }\n                }\n\n                // ShapeViewer: display shapes and texts from current node shape attributes and json files\n                // Note: use a Loader \n                ExifOrientedViewer {\n                    anchors.centerIn: parent\n                    width: imgContainer.width\n                    height: imgContainer.height\n                    xOrigin: imgContainer.width * 0.5\n                    yOrigin: imgContainer.height * 0.5\n                    orientationTag: imgContainer.orientationTag\n                    active: _currentScene ? (_currentScene.selectedNode ? _currentScene.selectedNode.hasDisplayableShape : false) : false\n\n                    onActiveChanged: {\n                        if (active) {\n                            setSource(\"../Shapes/Viewer/ShapeViewer.qml\", {\n                                \"containerWidth\": Qt.binding(function() { return imgContainer.width }),\n                                \"containerHeight\": Qt.binding(function() { return imgContainer.height }),\n                                \"containerScale\": Qt.binding(function() { return imgContainer.scale })\n                            })\n                        } else {\n                            // forcing the unload (instead of using Component.onCompleted to load it once and for all) is necessary since Qt 5.14\n                            setSource(\"\", {})\n\n                        }\n                    }\n                }\n\n                // FisheyeCircleViewer: display fisheye circle\n                // Note: use a Loader to evaluate if a PanoramaInit node exist and displayFisheyeCircle checked at runtime\n                ExifOrientedViewer {\n                    anchors.centerIn: parent\n                    orientationTag: imgContainer.orientationTag\n                    xOrigin: imgContainer.width / 2\n                    yOrigin: imgContainer.height / 2\n                    property var activeNode: _currentScene ? _currentScene.activeNodes.get(\"PanoramaInit\").node : null\n                    active: displayFisheyeCircleLoader.checked && activeNode\n\n                    sourceComponent: CircleGizmo {\n                        width: imgContainer.width\n                        height: imgContainer.height\n\n                        property bool useAuto: activeNode.attribute(\"estimateFisheyeCircle\").value\n                        readOnly: useAuto\n                        visible: (!useAuto) || activeNode.isComputed\n                        property real userFisheyeRadius: activeNode.attribute(\"fisheyeRadius\").value\n                        property variant fisheyeAutoParams: _currentScene.getAutoFisheyeCircle(activeNode)\n\n                        circleX: useAuto ? fisheyeAutoParams.x : activeNode.attribute(\"fisheyeCenterOffset.fisheyeCenterOffset_x\").value\n                        circleY: useAuto ? fisheyeAutoParams.y : activeNode.attribute(\"fisheyeCenterOffset.fisheyeCenterOffset_y\").value\n\n                        circleRadius: useAuto ? fisheyeAutoParams.z : ((imgContainer.image ? Math.min(imgContainer.image.width, imgContainer.image.height) : 1.0) * 0.5 * (userFisheyeRadius * 0.01))\n\n                        circleBorder.width: Math.max(1, (3.0 / imgContainer.scale))\n                        onMoved: function(xoffset, yoffset) {\n                            if (!useAuto) {\n                                _currentScene.setAttribute(\n                                    activeNode.attribute(\"fisheyeCenterOffset\"),\n                                    JSON.stringify([xoffset, yoffset])\n                                )\n                            }\n                        }\n                        onIncrementRadius: function(radiusOffset) {\n                            if (!useAuto) {\n                                _currentScene.setAttribute(activeNode.attribute(\"fisheyeRadius\"), activeNode.attribute(\"fisheyeRadius\").value + radiusOffset)\n                            }\n                        }\n                    }\n                }\n\n                // LightingCalibration: display circle\n                ExifOrientedViewer {\n                    property var activeNode: _currentScene.activeNodes.get(\"SphereDetection\").node \n    \n                    anchors.centerIn: parent\n                    orientationTag: imgContainer.orientationTag\n                    xOrigin: imgContainer.width / 2\n                    yOrigin: imgContainer.height / 2\n                    active: displayLightingCircleLoader.checked && activeNode\n\n                    sourceComponent: CircleGizmo {\n                        property var jsonFolder: activeNode.attribute(\"output\").value\n                        property var json: null\n                        property var currentViewId: _currentScene.selectedViewId\n                        property var nodeCircleX: activeNode.attribute(\"sphereCenter.x\").value\n                        property var nodeCircleY: activeNode.attribute(\"sphereCenter.y\").value\n                        property var nodeCircleRadius: activeNode.attribute(\"sphereRadius\").value\n                        \n                        width: imgContainer.width\n                        height: imgContainer.height\n                        readOnly: activeNode.attribute(\"autoDetect\").value\n                        circleX: nodeCircleX\n                        circleY: nodeCircleY\n                        circleRadius: nodeCircleRadius\n                        circleBorder.width: Math.max(1, (3.0 / imgContainer.scale))\n\n                        onJsonFolderChanged: {\n                            json = null\n                            if (activeNode.attribute(\"autoDetect\").value) {\n                                // Auto detection enabled\n                                var jsonPath = activeNode.attribute(\"output\").value\n                                Request.get(Filepath.stringToUrl(jsonPath), function(xhr) {\n                                    if (xhr.readyState === XMLHttpRequest.DONE) {\n                                        try {\n                                            json = JSON.parse(xhr.responseText)\n                                        } catch(exc) {\n                                            console.warn(\"Failed to parse SphereDetection JSON file: \" + jsonPath)\n                                        }\n                                    }\n                                    updateGizmo()\n                                })\n                            }\n                        }\n\n                        onCurrentViewIdChanged: { updateGizmo() }\n                        onNodeCircleXChanged : { updateGizmo() }\n                        onNodeCircleYChanged : { updateGizmo() }\n                        onNodeCircleRadiusChanged : { updateGizmo() }\n\n                        function updateGizmo() {\n                            if (activeNode.attribute(\"autoDetect\").value) {\n                                // Update gizmo from auto detection JSON file\n                                if (json) {\n                                    // JSON file found\n                                    var data = json[currentViewId]\n                                    if (data && data[0]) {\n                                        // Current view id found\n                                        circleX = data[0].x\n                                        circleY= data[0].y\n                                        circleRadius = data[0].r\n                                        return\n                                    }\n                                }\n                                // No auto detection data\n                                circleX = -1\n                                circleY= -1\n                                circleRadius = 0\n                            }\n                            else {\n                                // Update gizmo from node manual parameters\n                                circleX = nodeCircleX\n                                circleY = nodeCircleY\n                                circleRadius = nodeCircleRadius\n                            }\n                        }\n\n                        onMoved: {\n                            _currentScene.setAttribute(activeNode.attribute(\"sphereCenter\"),\n                                                         JSON.stringify([xoffset, yoffset]))\n                        }\n\n                        onIncrementRadius: {\n                            _currentScene.setAttribute(activeNode.attribute(\"sphereRadius\"),\n                                                         activeNode.attribute(\"sphereRadius\").value + radiusOffset)\n                        }\n                    }\n                }\n\n                // ColorCheckerViewer: display color checker detection results\n                // Note: use a Loader to evaluate if a ColorCheckerDetection node exist and displayColorChecker checked at runtime\n                ExifOrientedViewer {\n                    id: colorCheckerViewerLoader\n                    anchors.centerIn: parent\n                    orientationTag: imgContainer.orientationTag\n                    xOrigin: imgContainer.width / 2\n                    yOrigin: imgContainer.height / 2\n                    property var activeNode: _currentScene ? _currentScene.activeNodes.get(\"ColorCheckerDetection\").node : null\n                    active: (displayColorCheckerViewerLoader.checked && activeNode)\n\n                    sourceComponent: ColorCheckerViewer {\n                        width: imgContainer.width\n                        height: imgContainer.height\n\n                        visible: activeNode.isComputed && json !== undefined && imgContainer.image.imageStatus === Image.Ready\n                        source: Filepath.stringToUrl(activeNode.attribute(\"outputData\").value)\n                        viewpoint: _currentScene.selectedViewpoint\n                        zoom: imgContainer.scale\n\n                        updatePane: function() {\n                            colorCheckerPane.colors = getColors();\n                        }\n                    }\n                }\n            }\n\n            ColumnLayout {\n                anchors.fill: parent\n                spacing: 0\n\n                FloatingPane {\n                    id: imagePathToolbar\n                    Layout.fillWidth: true\n                    Layout.fillHeight: false\n                    Layout.preferredHeight: childrenRect.height\n                    visible: displayImagePathAction.checked\n\n                    RowLayout {\n                        width: parent.width\n                        height: childrenRect.height\n\n                        // Selectable filepath to source image\n                        TextField {\n                            padding: 0\n                            background: Item {}\n                            horizontalAlignment: TextInput.AlignLeft\n                            Layout.fillWidth: true\n                            height: contentHeight\n                            font.pointSize: 8\n                            readOnly: true\n                            selectByMouse: true\n                            text: (phongImageViewerLoader.active) ? Filepath.urlToString(phongImageViewerLoader.sourcePath) : Filepath.urlToString(getImageFile())\n                        }\n\n                        // Write which node is being displayed\n                        Label {\n                            id: displayedNodeName\n                            text: root.displayedNode ? root.displayedNode.label : \"\"\n                            font.pointSize: 8\n\n                            horizontalAlignment: TextInput.AlignLeft\n                            Layout.fillWidth: false\n                            Layout.preferredWidth: contentWidth\n                            height: contentHeight\n                        }\n\n                        // Button to clear currently displayed file\n                        MaterialToolButton {\n                            id: clearViewerButton\n                            text: MaterialIcons.close\n                            ToolTip.text: root.useExternal ? \"Close external file\" : \"Clear node\"\n                            enabled: root.displayedNode || root.useExternal\n                            visible: root.displayedNode || root.useExternal\n                            onClicked: {\n                                if (root.displayedNode)\n                                    root.displayedNode = null\n                                if (root.useExternal)\n                                    root.useExternal = false\n                            }\n                        }\n                    }\n                }\n\n                FloatingPane {\n                    Layout.fillWidth: true\n                    Layout.fillHeight: false\n                    Layout.preferredHeight: childrenRect.height\n                    visible: floatImageViewerLoader.item !== null && floatImageViewerLoader.item.imageStatus === Image.Error\n                    Layout.alignment: Qt.AlignHCenter\n\n                    RowLayout {\n                        anchors.fill: parent\n\n                        Label {\n                            font.pointSize: 8\n                            text: {\n                                if (floatImageViewerLoader.item !== null) {\n                                    switch (floatImageViewerLoader.item.status) {\n                                        case 2:  // AliceVision.FloatImageViewer.EStatus.OUTDATED_LOADING\n                                            return \"Outdated Loading\"\n                                        case 3:  // AliceVision.FloatImageViewer.EStatus.MISSING_FILE\n                                            return \"Missing File\"\n                                        case 4:  // AliceVision.FloatImageViewer.EStatus.LOADING_ERROR\n                                            return \"Error\"\n                                        default:\n                                            return \"\"\n                                    }\n                                }\n                                return \"\"\n                            }\n                            horizontalAlignment: Text.AlignHCenter\n                            verticalAlignment: Text.AlignVCenter\n                            Layout.fillWidth: true\n                            Layout.alignment: Qt.AlignHCenter\n                        }\n                    }\n                }\n                FloatingPane {\n                    Layout.fillWidth: true\n                    Layout.fillHeight: false\n                    Layout.preferredHeight: childrenRect.height\n                    visible: phongImageViewerLoader.item !== null && \n                             phongImageViewerLoader.item.imageStatus === Image.Error && \n                             phongImageViewerLoader.sourcePath != \"\"\n                    Layout.alignment: Qt.AlignHCenter\n                    RowLayout {\n                        anchors.fill: parent\n                        Label {\n                            font.pointSize: 8\n                            text: {\n                                if (phongImageViewerLoader.item !== null) {\n                                    switch (phongImageViewerLoader.item.status) {\n                                        case 2:  // AliceVision.PhongImageViewer.EStatus.MISSING_FILE\n                                            return \"Invalid / Missing File(s)\"\n                                        case 4:  // AliceVision.PhongImageViewer.EStatus.LOADING_ERROR\n                                            return \"Error\"\n                                        default:\n                                            return \"\"\n                                    }\n                                }\n                                return \"\"\n                            }\n                            horizontalAlignment: Text.AlignHCenter\n                            verticalAlignment: Text.AlignVCenter\n                            Layout.fillWidth: true\n                            Layout.alignment: Qt.AlignHCenter\n                        }\n                    }\n                }\n\n                Item {\n                    id: imgPlaceholder\n                    Layout.fillWidth: true\n                    Layout.fillHeight: true\n\n                    // Image Metadata overlay Pane\n                    ImageMetadataView {\n                        width: 350\n                        anchors {\n                            top: parent.top\n                            right: parent.right\n                            bottom: parent.bottom\n                        }\n\n                        visible: metadataCB.checked\n                        // Only load metadata model if visible\n                        metadata: {\n                            if (visible) {\n                                if (root.useExternal || outputAttribute.name != \"gallery\") {\n                                    return m.imgMetadata\n                                } else {\n                                    return m.viewpointMetadata\n                                }\n                            }\n                            return {}\n                        }\n                    }\n\n                    ColorCheckerPane {\n                        id: colorCheckerPane\n                        width: 250\n                        height: 170\n                        anchors {\n                            top: parent.top\n                            right: parent.right\n                        }\n                        visible: displayColorCheckerViewerLoader.checked && colorCheckerPane.colors !== null\n                    }\n\n                    Loader {\n                        id: mfeaturesLoader\n\n                        property bool isUsed: displayFeatures.checked\n                        property var activeNode: {\n                            if (!root.aliceVisionPluginAvailable) {\n                                return null\n                            }\n                            return _currentScene ? _currentScene.activeNodes.get(\"featureProvider\").node : null\n                        }\n                        property bool isComputed: activeNode && activeNode.isComputed\n                        active: isUsed && isComputed\n\n                        onActiveChanged: {\n                            if (active) {\n                                // Instantiate and initialize a MFeatures component dynamically using Loader.setSource\n                                // so it can fail safely if the C++ plugin is not available\n                                setSource(\"MFeatures.qml\", {\n                                    \"describerTypes\": Qt.binding(function() {\n                                        return activeNode ? activeNode.attribute(\"describerTypes\").value : {}\n                                    }),\n                                    \"featureFolders\": Qt.binding(function() {\n                                        let result = []\n                                        if (activeNode) {\n                                            if (activeNode.nodeType == \"FeatureExtraction\" && isComputed) {\n                                                result.push(activeNode.attribute(\"output\").value)\n                                            } \n                                            else if (activeNode.nodeType == \"RomaReducer\" && isComputed) {\n                                                result.push(activeNode.attribute(\"featuresFolder\").value)\n                                            }\n                                            else if (activeNode.hasAttribute(\"featuresFolders\")) {\n                                                for (let i = 0; i < activeNode.attribute(\"featuresFolders\").value.count; i++) {\n                                                    let attr = activeNode.attribute(\"featuresFolders\").value.at(i)\n                                                    result.push(attr.value)\n                                                }\n                                            }\n                                        }\n                                        return result\n                                    }),\n                                    \"viewIds\": Qt.binding(function() {\n                                        if (_currentScene) {\n                                            let result = [];\n                                            for (let i = 0; i < _currentScene.viewpoints.count; i++) {\n                                                let vp = _currentScene.viewpoints.at(i)\n                                                result.push(vp.childAttribute(\"viewId\").value)\n                                            }\n                                            return result\n                                        }\n                                        return {}\n                                    }),\n                                })\n                            } else {\n                                // Forcing the unload (instead of using Component.onCompleted to load it once and for all) is necessary since Qt 5.14\n                                setSource(\"\", {})\n                            }\n                        }\n                    }\n\n                    Loader {\n                        id: msfmDataLoader\n\n                        property bool isUsed: displayFeatures.checked || displaySfmStatsView.checked || displaySfmDataGlobalStats.checked\n                                              || displayPanoramaViewer.checked || displayLensDistortionViewer.checked\n                        property var activeNode: {\n                            if (!root.aliceVisionPluginAvailable) {\n                                return null\n                            }\n                            var nodeType = \"sfm\"\n                            if (displayLensDistortionViewer.checked) {\n                                nodeType = \"sfmData\"\n                            }\n                            var sfmNode = _currentScene ? _currentScene.activeNodes.get(nodeType).node : null\n                            if (sfmNode === null) {\n                                return null\n                            }\n                            if (displayPanoramaViewer.checked) {\n                                sfmNode = _currentScene.activeNodes.get('SfMTransform').node\n                                var previousNode = sfmNode.attribute(\"input\").inputRootLink.node\n                                return previousNode\n                            }\n                            return sfmNode\n                        }\n                        property bool isComputed: activeNode && activeNode.isComputed\n                        property string filepath: {\n                            var sfmValue = \"\"\n                            if (isComputed && activeNode.hasAttribute(\"output\")) {\n                                sfmValue = activeNode.attribute(\"output\").value\n                            }\n                            return Filepath.stringToUrl(sfmValue)\n                        }\n\n                        active: isUsed && isComputed\n\n                        onActiveChanged: {\n                            if (active) {\n                                // Instantiate and initialize a SfmStatsView component dynamically using Loader.setSource\n                                // so it can fail safely if the c++ plugin is not available\n                                setSource(\"MSfMData.qml\", {\n                                    \"sfmDataPath\": Qt.binding(function() { return filepath }),\n                                })\n                            } else {\n                                // Force the unload (instead of using Component.onCompleted to load it once and for all) is necessary since Qt 5.14\n                                setSource(\"\", {})\n                            }\n                        }\n                    }\n\n                    Loader {\n                        id: mtracksLoader\n\n                        property bool isUsed: displayFeatures.checked || displaySfmStatsView.checked || displaySfmDataGlobalStats.checked || displayPanoramaViewer.checked\n                        property var activeNode: {\n                            if (!root.aliceVisionPluginAvailable) {\n                                return null\n                            }\n\n                            if (_currentScene)\n                            {\n                                //Try first to use tracks\n                                if (_currentScene.activeNodes.get(\"trackProvider\").node)\n                                {\n                                    return _currentScene.activeNodes.get(\"trackProvider\").node\n                                }\n\n                                return _currentScene.activeNodes.get(\"matchProvider\").node\n                            }\n\n                            return null\n                        }\n\n                        property bool isComputed: activeNode && activeNode.isComputed\n\n                        active: isUsed && isComputed\n\n                        onActiveChanged: {\n                            if (active) {\n                                // instantiate and initialize a mTracks component \n                                // dynamically using Loader.setSource so it can fail safely \n                                // if the c++ plugin is not available\n                                setSource(\"MTracks.qml\", {\n                                    \"matchingFolders\": Qt.binding(function() {\n                                        let result = []\n                                        if (activeNode) {\n                                            if (activeNode.nodeType == \"FeatureMatching\" && isComputed) {\n                                                result.push(activeNode.attribute(\"output\").value)\n                                            } else if (activeNode.hasAttribute(\"matchesFolders\")) {\n                                                for (let i = 0; i < activeNode.attribute(\"matchesFolders\").value.count; i++) {\n                                                    let attr = activeNode.attribute(\"matchesFolders\").value.at(i)\n                                                    result.push(attr.value)\n                                                }\n                                            }\n                                        }\n                                        return result\n                                    }),\n                                    \"tracksFile\": Qt.binding(function() {\n                                        let result = \"\"\n                                        if (activeNode) {\n                                            if (activeNode.nodeType == \"TracksBuilding\" && isComputed) {\n                                                result = activeNode.attribute(\"output\").value\n                                            }\n                                            else if (activeNode.hasAttribute(\"tracksFilename\")) {\n                                                result = activeNode.attribute(\"tracksFilename\").value\n                                            }\n                                        }\n                                        return result\n                                    })\n                                })\n                            } else {\n                                // Forcing the unload (instead of using Component.onCompleted to load it once and for all) is necessary since Qt 5.14\n                                setSource(\"\", {})\n                            }\n                        }\n                    }\n\n                    Loader {\n                        id: sfmStatsView\n                        anchors.fill: parent\n                        active: msfmDataLoader.status === Loader.Ready && displaySfmStatsView.checked\n\n                        onActiveChanged: {\n                            // Load and unload the component explicitly\n                            // (necessary since Qt 5.14, Component.onCompleted cannot be used anymore to load the data once and for all)\n                            if (active) {\n                                setSource(\"SfmStatsView.qml\", {\n                                    \"msfmData\": Qt.binding(function() { return msfmDataLoader.item }),\n                                    \"viewId\": Qt.binding(function() { return _currentScene.selectedViewId }),\n                                })\n                            } else {\n                                setSource(\"\", {})\n                            }\n                        }\n                    }\n\n                    Loader {\n                        id: sfmGlobalStats\n                        anchors.fill: parent\n                        active: msfmDataLoader.status === Loader.Ready && displaySfmDataGlobalStats.checked\n\n                        onActiveChanged: {\n                            // Load and unload the component explicitly\n                            // (necessary since Qt 5.14, Component.onCompleted cannot be used anymore to load the data once and for all)\n                            if (active) {\n                                setSource(\"SfmGlobalStats.qml\", {\n                                    \"msfmData\": Qt.binding(function() { return msfmDataLoader.item }),\n                                    \"mTracks\": Qt.binding(function() { return mtracksLoader.item }),\n\n                                })\n                            } else {\n                                setSource(\"\", {})\n                            }\n                        }\n                    }\n\n                    Loader {\n                        id: featuresOverlay\n                        anchors {\n                            bottom: parent.bottom\n                            left: parent.left\n                            margins: 2\n                        }\n                        active: root.aliceVisionPluginAvailable && displayFeatures.checked &&\n                                featuresViewerLoader.status === Loader.Ready\n\n                        sourceComponent: FeaturesInfoOverlay {\n                            pluginStatus: featuresViewerLoader.status\n                            featuresViewer: featuresViewerLoader.item\n                            mfeatures: mfeaturesLoader.item\n                            mtracks: mtracksLoader.item\n                            msfmdata: msfmDataLoader.item\n                            featuresNodeName: (mfeaturesLoader.activeNode) ? mfeaturesLoader.activeNode.label : \"None\"\n                            tracksNodeName: (mtracksLoader.activeNode) ? mtracksLoader.activeNode.label : \"None\"\n                            sfmdataNodeName: (msfmDataLoader.activeNode) ? msfmDataLoader.activeNode.label : \"None\"\n                        }\n                    }\n\n                    Loader {\n                        id: ldrHdrCalibrationGraph\n                        anchors.fill: parent\n\n                        property var activeNode: _currentScene ? _currentScene.activeNodes.get(\"LdrToHdrCalibration\").node : null\n                        property var isEnabled: displayLdrHdrCalibrationGraph.checked && activeNode && activeNode.isComputed\n                        active: isEnabled\n\n                        property var path: activeNode && activeNode.hasAttribute(\"response\") ? activeNode.attribute(\"response\").value : \"\"\n                        property var vp: _currentScene ? getViewpoint(_currentScene.selectedViewId) : null\n\n                        sourceComponent: CameraResponseGraph {\n                            responsePath: Filepath.resolve(path, vp)\n                        }\n                    }\n\n                    Loader {\n                        id: phongImageViewerToolbarLoader\n                        active: phongImageViewerLoader.status === Loader.Ready\n                        anchors {\n                            bottom: parent.bottom\n                            left: parent.left\n                            margins: 2\n                        }\n                        sourceComponent: PhongImageViewerToolbar {\n                        }\n                    }\n\n                    Loader {\n                        id: directionalLightPaneLoader\n                        active: phongImageViewerToolbarLoader.status === Loader.Ready\n                        anchors {\n                            bottom: parent.bottom\n                            right: parent.right\n                            margins: 2\n                        }\n                        sourceComponent: DirectionalLightPane {\n                            visible: phongImageViewerToolbarLoader.item !== null && phongImageViewerToolbarLoader.item.displayLightController\n                        }\n                    }\n                }\n\n                FloatingPane {\n                    id: bottomToolbar\n                    padding: 4\n                    Layout.fillWidth: true\n                    Layout.preferredHeight: childrenRect.height\n\n                    RowLayout {\n                        anchors.fill: parent\n\n                        // Zoom label\n                        MLabel {\n                            text: ((imgContainer.image && (imgContainer.image.imageStatus === Image.Ready)) ? imgContainer.scale.toFixed(2) : \"1.00\") + \"x\"\n                            ToolTip.text: \"Zoom\"\n\n                            MouseArea {\n                                anchors.fill: parent\n                                acceptedButtons: Qt.LeftButton | Qt.RightButton\n                                onClicked: function(mouse) {\n                                    if (mouse.button & Qt.LeftButton) {\n                                        fit()\n                                    } else if (mouse.button & Qt.RightButton) {\n                                        var menu = contextMenu.createObject(root)\n                                        var point = mapToItem(root, mouse.x, mouse.y)\n                                        menu.x = point.x\n                                        menu.y = point.y\n                                        menu.mousePos = Qt.point(point.x, point.y)\n                                        menu.open()\n                                    }\n                                }\n                            }\n                        }\n\n                        MaterialToolButton {\n                            id: displayAlphaBackground\n                            ToolTip.text: \"Alpha Background\"\n                            text: MaterialIcons.texture\n                            font.pointSize: 11\n                            Layout.minimumWidth: 0\n                            checkable: true\n                        }\n\n                        MaterialToolButton {\n                            id: displayHDR\n                            ToolTip.text: \"High-Dynamic-Range Image Viewer\"\n                            text: MaterialIcons.hdr_on\n                            // Larger font but smaller padding, so it is visually similar\n                            font.pointSize: 20\n                            padding: 0\n                            Layout.minimumWidth: 0\n                            checkable: true\n                            checked: root.aliceVisionPluginAvailable\n                            enabled: root.aliceVisionPluginAvailable\n                            visible: root.enable8bitViewer\n                            onCheckedChanged : {\n                                if (displayLensDistortionViewer.checked && checked) {\n                                    displayLensDistortionViewer.checked = false\n                                }\n                                root.useFloatImageViewer = !root.useFloatImageViewer\n                            }\n                        }\n\n                        MaterialToolButton {\n                            id: displayLensDistortionViewer\n                            property int numberChanges: 0\n                            property bool previousChecked: false\n                            property var activeNode: root.aliceVisionPluginAvailable && _currentScene ? _currentScene.activeNodes.get(\"sfmData\").node : null\n                            property bool isComputed: {\n                                if (!activeNode)\n                                    return false\n                                if (activeNode.isComputed)\n                                    return true\n                                if (!activeNode.hasAttribute(\"input\"))\n                                    return false\n                                var inputAttr = activeNode.attribute(\"input\")\n                                var inputAttrLink = inputAttr.inputRootLink\n                                if (!inputAttrLink)\n                                    return false\n                                return inputAttrLink.node.isComputed\n                            }\n\n                            ToolTip.text: \"Lens Distortion Viewer\" + (isComputed ? (\": \" + activeNode.label) : \"\")\n                            text: MaterialIcons.panorama_horizontal\n                            font.pointSize: 16\n                            padding: 0\n                            Layout.minimumWidth: 0\n                            checkable: true\n                            checked: false\n                            enabled: activeNode && isComputed\n                            onCheckedChanged : {\n                                if ((displayHDR.checked || displayPanoramaViewer.checked) && checked) {\n                                    displayHDR.checked = false\n                                    displayPanoramaViewer.checked = false\n                                } else if (!checked) {\n                                    displayHDR.checked = true\n                                }\n                            }\n\n                            onActiveNodeChanged: {\n                                numberChanges += 1\n                            }\n\n                            onEnabledChanged: {\n                                if (!enabled) {\n                                    previousChecked = checked\n                                    checked = false\n                                    numberChanges = 0\n                                }\n\n                                if (enabled && (numberChanges == 1) && previousChecked) {\n                                    checked = true\n                                }\n                            }\n                        }\n\n                        MaterialToolButton {\n                            id: displayPanoramaViewer\n                            property var activeNode: root.aliceVisionPluginAvailable && _currentScene ? _currentScene.activeNodes.get(\"SfMTransform\").node : null\n                            property bool isComputed: {\n                                if (!activeNode)\n                                    return false\n                                if (activeNode.attribute(\"method\").value !== \"manual\")\n                                    return false\n                                var inputAttr = activeNode.attribute(\"input\")\n                                if (!inputAttr)\n                                    return false\n                                var inputAttrLink = inputAttr.inputRootLink\n                                if (!inputAttrLink)\n                                    return false\n                                return inputAttrLink.node.isComputed\n                            }\n\n                            ToolTip.text: activeNode ? \"Panorama Viewer \" + activeNode.label : \"Panorama Viewer\"\n                            text: MaterialIcons.panorama_photosphere\n                            font.pointSize: 16\n                            padding: 0\n                            Layout.minimumWidth: 0\n                            checkable: true\n                            checked: false\n                            enabled: activeNode && isComputed\n                            onCheckedChanged : {\n                                if (displayLensDistortionViewer.checked && checked) {\n                                    displayLensDistortionViewer.checked = false\n                                }\n                                if (displayFisheyeCircleLoader.checked && checked) {\n                                    displayFisheyeCircleLoader.checked = false\n                                }\n                            }\n                            onEnabledChanged : {\n                                if (!enabled) {\n                                    checked = false\n                                }\n                            }\n                        }\n\n                        MaterialToolButton {\n                            id: displayFeatures\n                            ToolTip.text: \"Display Features\"\n                            text: MaterialIcons.scatter_plot\n                            font.pointSize: 11\n                            Layout.minimumWidth: 0\n                            checkable: true && !useExternal\n                            checked: false\n                            enabled: root.aliceVisionPluginAvailable && !displayPanoramaViewer.checked && !useExternal\n                            onEnabledChanged : {\n                                if (useExternal)\n                                    return\n                                if (enabled == false)\n                                    checked = false\n                            }\n                        }\n\n                        MaterialToolButton {\n                            id: displayFisheyeCircleLoader\n                            property var activeNode: _currentScene ? _currentScene.activeNodes.get(\"PanoramaInit\").node : null\n                            ToolTip.text: \"Display Fisheye Circle: \" + (activeNode ? activeNode.label : \"No Node\")\n                            text: MaterialIcons.vignette\n                            font.pointSize: 11\n                            Layout.minimumWidth: 0\n                            checkable: true\n                            checked: false\n                            enabled: activeNode && activeNode.attribute(\"useFisheye\").value && !displayPanoramaViewer.checked\n                            visible: activeNode\n                        }\n\n                        MaterialToolButton {\n                            id: displayLightingCircleLoader\n                            property var activeNode: _currentScene.activeNodes.get(\"SphereDetection\").node\n                            ToolTip.text: \"Display Lighting Circle: \" + (activeNode ? activeNode.label : \"No Node\")\n                            text: MaterialIcons.location_searching\n                            font.pointSize: 11\n                            Layout.minimumWidth: 0\n                            checkable: true\n                            checked: false\n                            enabled: activeNode\n                            visible: activeNode\n                        }\n\n                        MaterialToolButton {\n                            id: displayPhongLighting\n                            property var activeNode: _currentScene.activeNodes.get('PhotometricStereo').node\n                            ToolTip.text: \"Display Phong Lighting: \" + (activeNode ? activeNode.label : \"No Node\")\n                            text: MaterialIcons.light_mode\n                            font.pointSize: 11\n                            Layout.minimumWidth: 0\n                            checkable: true\n                            checked: false\n                            enabled: activeNode\n                            visible: activeNode\n                        }\n                        MaterialToolButton {\n                            id: displayColorCheckerViewerLoader\n                            property var activeNode: _currentScene ? _currentScene.activeNodes.get(\"ColorCheckerDetection\").node : null\n                            ToolTip.text: \"Display Color Checker: \" + (activeNode ? activeNode.label : \"No Node\")\n                            text: MaterialIcons.view_comfy\n                            font.pointSize: 11\n                            Layout.minimumWidth: 0\n                            checkable: true\n                            enabled: activeNode && activeNode.isComputed && _currentScene.selectedViewId !== -1\n                            checked: false\n                            visible: activeNode\n                            onEnabledChanged: {\n                                if (enabled == false)\n                                    checked = false\n                            }\n                            onCheckedChanged: {\n                                if (checked == true) {\n                                    displaySfmDataGlobalStats.checked = false\n                                    displaySfmStatsView.checked = false\n                                    metadataCB.checked = false\n                                }\n                            }\n                        }\n\n                        MaterialToolButton {\n                            id: displayLdrHdrCalibrationGraph\n                            property var activeNode: _currentScene ? _currentScene.activeNodes.get(\"LdrToHdrCalibration\").node : null\n                            property bool isComputed: activeNode && activeNode.isComputed\n                            ToolTip.text: \"Display Camera Response Function: \" + (activeNode ? activeNode.label : \"No Node\")\n                            text: MaterialIcons.timeline\n                            font.pointSize: 11\n                            Layout.minimumWidth: 0\n                            checkable: true\n                            checked: false\n                            enabled: activeNode && activeNode.isComputed\n                            visible: activeNode\n\n                            onIsComputedChanged: {\n                                if (!isComputed)\n                                    checked = false\n                            }\n                        }\n\n                        Label {\n                            id: resolutionLabel\n                            Layout.fillWidth: true\n                            text: (imgContainer.image && imgContainer.image.sourceSize.width > 0) ? (imgContainer.image.sourceSize.width + \"x\" + imgContainer.image.sourceSize.height) : \"\"\n\n                            elide: Text.ElideRight\n                            horizontalAlignment: Text.AlignHCenter\n                        }\n\n                        ComboBox {\n                            id: outputAttribute\n                            clip: true\n                            Layout.minimumWidth: 0\n                            flat: true\n\n                            property var names: [\"gallery\"]\n                            property string name: names[currentIndex]\n                            property string lastOutputName: \"\"\n\n                            model: names.map(n => (n === \"gallery\") ? \"Image Gallery\" : displayedNode.attributes.get(n).label)\n                            enabled: count > 1\n\n                            FontMetrics {\n                                id: fontMetrics\n                            }\n                            Layout.preferredWidth: model.reduce((acc, label) => Math.max(acc, fontMetrics.boundingRect(label).width), 0) + 3.0 * Qt.application.font.pixelSize\n\n                            onNameChanged: {\n                                if (name !== \"gallery\")\n                                    lastOutputName = name\n                                root.source = getImageFile()\n                                root.sequence = getSequence()\n                            }\n\n                            function setName(attrName) {\n                                const attrIndex = outputAttribute.names.indexOf(attrName)\n                                if (attrIndex > -1) {\n                                    outputAttribute.currentIndex = attrIndex\n                                }\n                            }\n                        }\n\n                        MaterialToolButton {\n                            id: displayImageOutputIn3D\n                            enabled: root.aliceVisionPluginAvailable && _currentScene && displayedNode && Filepath.basename(root.source).includes(\"depth\")\n                            ToolTip.text: \"View Depth Map in 3D\"\n                            text: MaterialIcons.input\n                            font.pointSize: 11\n                            Layout.minimumWidth: 0\n\n                            onClicked: {\n                                root.viewIn3D(\n                                    root.source,\n                                    displayedNode.name + \":\" + outputAttribute.name + \" \" + String(_currentScene.selectedViewId)\n                                )\n                            }\n                        }\n\n                        MaterialToolButton {\n                            id: displaySfmStatsView\n                            property var activeNode: root.aliceVisionPluginAvailable && _currentScene ? _currentScene.activeNodes.get(\"sfm\").node : null\n                            property bool isComputed: activeNode && activeNode.isComputed\n\n                            font.family: MaterialIcons.fontFamily\n                            text: MaterialIcons.assessment\n\n                            ToolTip.text: \"StructureFromMotion Statistics\" + (isComputed ? (\": \" + activeNode.label) : \"\")\n                            ToolTip.visible: hovered\n\n                            font.pointSize: 14\n                            padding: 2\n                            smooth: false\n                            flat: true\n                            checkable: enabled\n                            enabled: activeNode && activeNode.isComputed && _currentScene.selectedViewId >= 0\n                            onCheckedChanged: {\n                                if (checked == true) {\n                                    displaySfmDataGlobalStats.checked = false\n                                    metadataCB.checked = false\n                                    displayColorCheckerViewerLoader.checked = false\n                                }\n                            }\n                        }\n\n                        MaterialToolButton {\n                            id: displaySfmDataGlobalStats\n                            property var activeNode: root.aliceVisionPluginAvailable && _currentScene ? _currentScene.activeNodes.get(\"sfm\").node : null\n                            property bool isComputed: activeNode && activeNode.isComputed\n\n                            font.family: MaterialIcons.fontFamily\n                            text: MaterialIcons.language\n\n                            ToolTip.text: \"StructureFromMotion Global Statistics\" + (isComputed ? (\": \" + activeNode.label) : \"\")\n                            ToolTip.visible: hovered\n\n                            font.pointSize: 14\n                            padding: 2\n                            smooth: false\n                            flat: true\n                            checkable: enabled\n                            enabled: activeNode && activeNode.isComputed\n                            onCheckedChanged: {\n                                if (checked == true) {\n                                    displaySfmStatsView.checked = false\n                                    metadataCB.checked = false\n                                    displayColorCheckerViewerLoader.checked = false\n                                }\n                            }\n                        }\n\n                        MaterialToolButton {\n                            id: metadataCB\n\n                            font.family: MaterialIcons.fontFamily\n                            text: MaterialIcons.info_outline\n\n                            ToolTip.text: \"Image Metadata\"\n                            ToolTip.visible: hovered\n\n                            font.pointSize: 14\n                            padding: 2\n                            smooth: false\n                            flat: true\n                            checkable: enabled\n                            onCheckedChanged: {\n                                if (checked == true) {\n                                    displaySfmDataGlobalStats.checked = false\n                                    displaySfmStatsView.checked = false\n                                    displayColorCheckerViewerLoader.checked = false\n                                }\n                            }\n                        }\n                    }\n                }\n\n                SequencePlayer {\n                    id: sequencePlayer\n                    anchors.margins: 0\n                    Layout.fillWidth: true\n                    sortedViewIds: {\n                        return (root.enableSequencePlayer && (root.displayedNode && root.displayedNode.hasSequenceOutput)) ?\n                                    root.sequence :\n                                    (_currentScene && _currentScene.viewpoints.count > 0) ? buildOrderedSequence(\"<VIEW_ID>\") : []\n                    }\n                    viewer: floatImageViewerLoader.status === Loader.Ready ? floatImageViewerLoader.item : null\n                    visible: root.enableSequencePlayer\n                    enabled: root.enableSequencePlayer\n                    isOutputSequence: root.displayedNode && root.displayedNode.hasSequenceOutput\n                }\n            }\n        }\n    }\n\n    // Busy indicator\n    BusyIndicator {\n        anchors.centerIn: parent\n        // Running property binding seems broken, only dynamic binding assignment works\n        Component.onCompleted: {\n            running = Qt.binding(function() {\n                return (root.usePanoramaViewer === true && imgContainer.image && imgContainer.image.allImagesLoaded === false)\n                       || (imgContainer.image && imgContainer.image.imageStatus === Image.Loading)\n            })\n        }\n        // Disable the visibility when unused to avoid stealing the mouseEvent to the image color picker\n        visible: running\n\n        onVisibleChanged: {\n            if (panoramaViewerLoader.active)\n                fit()\n        }\n    }\n\n    // Actions for RGBA filters\n    Action {\n        id: rFilterAction\n\n        shortcut: \"R\"\n        onTriggered: {\n            hdrImageToolbar.toggleChannel(\"r\", \"rgba\")\n        }\n    }\n\n    Action {\n        id: gFilterAction\n\n        shortcut: \"G\"\n        onTriggered: {\n            hdrImageToolbar.toggleChannel(\"g\", \"rgba\")\n        }\n    }\n\n    Action {\n        id: bFilterAction\n\n        shortcut: \"B\"\n        onTriggered: {\n            hdrImageToolbar.toggleChannel(\"b\", \"rgba\")\n        }\n    }\n\n    Action {\n        id: aFilterAction\n\n        shortcut: \"A\"\n        onTriggered: {\n            hdrImageToolbar.toggleChannel(\"a\", \"rgba\")\n        }\n    }\n\n    // Actions for Metadata overlay\n    Action {\n        id: metadataAction\n\n        shortcut: \"I\"\n        onTriggered: {\n            metadataCB.checked = !metadataCB.checked\n        }\n    }\n\n    // Actions switch Source\n    Action {\n        id: switchSourceAction\n\n        shortcut: \"S\"\n        onTriggered: {\n            if (outputAttribute.name === \"gallery\") {\n                outputAttribute.setName(outputAttribute.lastOutputName)\n            } else {\n                outputAttribute.setName(\"gallery\")\n            }\n        }\n    }\n}\n"
  },
  {
    "path": "meshroom/ui/qml/Viewer/qmldir",
    "content": "module Viewer\n\nViewer2D 1.0 Viewer2D.qml\nImageMetadataView 1.0 ImageMetadataView.qml\nTextViewer 1.0 TextViewer.qml\n"
  },
  {
    "path": "meshroom/ui/qml/Viewer3D/BoundingBox.qml",
    "content": "import Qt3D.Core 2.6\nimport Qt3D.Render 2.6\nimport Qt3D.Input 2.6\nimport Qt3D.Extras 2.15\nimport QtQuick\n\nEntity {\n    id: root\n    property Transform transform: Transform {}\n\n    components: [transform]\n\n    Entity {\n        components: [cube, greyMaterial]\n\n        CuboidMesh {\n            id: cube\n            property real edge : 1.995  // Almost 2: important to have all the cube's vertices with a unit of 1\n            xExtent: edge\n            yExtent: edge\n            zExtent: edge\n        }\n        PhongAlphaMaterial {\n            id: greyMaterial\n            property color base: \"#fff\"\n            ambient: base\n            alpha: 0.15\n\n            // Pretty convincing combination\n            blendFunctionArg: BlendEquation.Add\n            sourceRgbArg: BlendEquationArguments.SourceAlpha\n            sourceAlphaArg: BlendEquationArguments.OneMinusSourceAlpha\n            destinationRgbArg: BlendEquationArguments.DestinationColor\n            destinationAlphaArg: BlendEquationArguments.OneMinusSourceAlpha\n        }\n    }\n\n    Entity {\n        components: [edges, orangeMaterial]\n\n        PhongMaterial {\n            id: orangeMaterial\n            property color base: \"#f49b2b\"\n            ambient: base\n        }\n\n        GeometryRenderer {\n            id: edges\n            primitiveType: GeometryRenderer.Lines\n            geometry: Geometry {\n                Attribute {\n                    id: boundingBoxPosition\n                    attributeType: Attribute.VertexAttribute\n                    vertexBaseType: Attribute.Float\n                    vertexSize: 3\n                    count: 24\n                    name: defaultPositionAttributeName\n                    buffer: Buffer {\n                        data: new Float32Array([\n                            1.0, 1.0, 1.0,\n                            1.0, -1.0, 1.0,\n                            1.0, 1.0, 1.0,\n                            1.0, 1.0, -1.0,\n                            1.0, 1.0, 1.0,\n                            -1.0, 1.0, 1.0,\n                            -1.0, -1.0, -1.0,\n                            -1.0, 1.0, -1.0,\n                            -1.0, -1.0, -1.0,\n                            1.0, -1.0, -1.0,\n                            -1.0, -1.0, -1.0,\n                            -1.0, -1.0, 1.0,\n                            1.0, -1.0, 1.0,\n                            1.0, -1.0, -1.0,\n                            1.0, 1.0, -1.0,\n                            1.0, -1.0, -1.0,\n                            -1.0, 1.0, 1.0,\n                            -1.0, 1.0, -1.0,\n                            1.0, -1.0, 1.0,\n                            -1.0, -1.0, 1.0,\n                            -1.0, 1.0, 1.0,\n                            -1.0, -1.0, 1.0,\n                            -1.0, 1.0, -1.0,\n                            1.0, 1.0, -1.0\n                            ])\n                    }\n                }\n                boundingVolumePositionAttribute: boundingBoxPosition\n            }\n        }\n    }\n}\n"
  },
  {
    "path": "meshroom/ui/qml/Viewer3D/DefaultCameraController.qml",
    "content": "import QtQuick\nimport Qt3D.Core 2.6\nimport Qt3D.Render 2.6\nimport Qt3D.Input 2.6\nimport Qt3D.Logic 2.6\nimport QtQml\n\nimport Meshroom.Helpers 1.0\n\nEntity {\n    id: root\n\n    property Camera camera\n    property real translateSpeed: 75.0\n    property real tiltSpeed: 500.0\n    property real panSpeed: 500.0\n    property alias focus: keyboardHandler.focus\n    readonly property bool pickingActive: actionControl.active && keyboardHandler._pressed\n    property alias rotationSpeed: trackball.rotationSpeed\n    property alias windowSize: trackball.windowSize\n    property alias trackballSize: trackball.trackballSize\n\n    property bool loseMouseFocus: false  // Must be changed by other entities when they want to take mouse focus\n\n    property bool moving: false\n    property bool panning: false\n    property bool zooming: false\n\n    readonly property alias pressed: mouseHandler._pressed\n    signal mousePressed(var mouse)\n    signal mouseReleased(var mouse, var moved)\n    signal mouseClicked(var mouse)\n    signal mouseWheeled(var wheel)\n    signal mouseDoubleClicked(var mouse)\n\n    KeyboardDevice { id: keyboardSourceDevice }\n    MouseDevice { id: mouseSourceDevice }\n\n    TrackballController {\n        id: trackball\n        camera: root.camera\n    }\n\n    MouseHandler {\n        id: mouseHandler\n        property bool _pressed\n        property point lastPosition\n        property point currentPosition\n        property bool hasMoved\n        sourceDevice: loseMouseFocus ? null : mouseSourceDevice\n        onPressed: function(mouse) {\n            _pressed = true\n            currentPosition.x = lastPosition.x = mouse.x\n            currentPosition.y = lastPosition.y = mouse.y\n            hasMoved = false\n            mousePressed(mouse)\n        }\n        onReleased: function(mouse) {\n            _pressed = false\n            mouseReleased(mouse, hasMoved)\n        }\n        onClicked: function(mouse) {\n            mouseClicked(mouse)\n        }\n        onPositionChanged: function(mouse) {\n            currentPosition.x = mouse.x\n            currentPosition.y = mouse.y\n\n            const dt = 0.02\n            var d\n\n            root.moving = mouse.buttons & Qt.LeftButton\n            root.panning = (mouse.buttons & Qt.MiddleButton)\n            var panningAlt = actionShift.active && (mouse.buttons & Qt.LeftButton)\n            root.zooming = actionAlt.active && (mouse.buttons & Qt.RightButton)\n\n            if (panning || panningAlt) {  // Translate\n                d = (root.camera.viewCenter.minus(root.camera.position)).length() * 0.03\n                var tx = axisMX.value * root.translateSpeed * d\n                var ty = axisMY.value * root.translateSpeed * d\n                mouseHandler.hasMoved = true\n                root.camera.translate(Qt.vector3d(-tx, -ty, 0).times(dt))\n                return\n            }\n            if (moving) {  // Trackball rotation\n                trackball.rotate(mouseHandler.lastPosition, mouseHandler.currentPosition, dt)\n                mouseHandler.lastPosition = mouseHandler.currentPosition\n                mouseHandler.hasMoved = true\n                return\n            }\n            if (zooming) {  // Zoom with alt + RMD\n                mouseHandler.hasMoved = true\n                d = root.camera.viewCenter.minus(root.camera.position).length()  // Distance between camera position and center position\n                var zoomPower = 0.2\n                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\n                var tzThreshold = 0.001\n\n                // 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)\n                if (tz >= 0.9 * d)\n                    return\n\n                // 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)\n                if (tz > 0 && tz <= tzThreshold)\n                    return\n\n                root.camera.translate(Qt.vector3d(0, 0, tz), Camera.DontTranslateViewCenter)\n                return\n            }\n        }\n\n        onDoubleClicked: function(mouse) { mouseDoubleClicked(mouse) }\n        onWheel: function(wheel) {\n            var d = root.camera.viewCenter.minus(root.camera.position).length()  // Distance between camera position and center position\n            var zoomPower = 0.2\n            var angleStep = 120\n            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\n            var tzThreshold = 0.001\n\n            // 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)\n            if (tz >= 0.9 * d) {\n                return\n            }\n\n            // 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)\n            if (tz > 0 && tz <= tzThreshold) {\n                return\n            }\n\n            root.camera.translate(Qt.vector3d(0, 0, tz), Camera.DontTranslateViewCenter)\n        }\n    }\n\n    KeyboardHandler {\n        id: keyboardHandler\n        sourceDevice: keyboardSourceDevice\n        property bool _pressed\n\n        // When focus is lost while pressing a key, the corresponding action stays active, even when it is released.\n        // Handle this issue manually by keeping an additional _pressed state\n        // which is cleared when focus changes (used for 'pickingActive' property).\n        onFocusChanged: function(focus) {\n            if (!focus)\n                _pressed = false\n        }\n        onPressed: _pressed = true\n        onReleased: _pressed = false\n    }\n\n    LogicalDevice {\n        id: cameraControlDevice\n        actions: [\n            Action {\n                id: actionLMB\n                inputs: [\n                    ActionInput {\n                        sourceDevice: mouseSourceDevice\n                        buttons: [MouseEvent.LeftButton]\n                    }\n                ]\n            },\n            Action {\n                id: actionRMB\n                inputs: [\n                    ActionInput {\n                        sourceDevice: mouseSourceDevice\n                        buttons: [MouseEvent.RightButton]\n                    }\n                ]\n            },\n            Action {\n                id: actionMMB\n                inputs: [\n                    ActionInput {\n                        sourceDevice: mouseSourceDevice\n                        buttons: [MouseEvent.MiddleButton]\n                    }\n                ]\n            },\n            Action {\n                id: actionShift\n                inputs: [\n                    ActionInput {\n                        sourceDevice: keyboardSourceDevice\n                        buttons: [Qt.Key_Shift]\n                    }\n                ]\n            },\n            Action {\n                id: actionControl\n                inputs: [\n                    ActionInput {\n                        sourceDevice: keyboardSourceDevice\n                        buttons: [Qt.Key_Control]\n                    }\n                ]\n            },\n            Action {\n                id: actionAlt\n                inputs: [\n                    ActionInput {\n                        sourceDevice: keyboardSourceDevice\n                        buttons: [Qt.Key_Alt]\n                    }\n                ]\n            }\n        ]\n        axes: [\n            Axis {\n                id: axisMX\n                inputs: [\n                    AnalogAxisInput {\n                        sourceDevice: mouseSourceDevice\n                        axis: MouseDevice.X\n                    }\n                ]\n            },\n            Axis {\n                id: axisMY\n                inputs: [\n                    AnalogAxisInput {\n                        sourceDevice: mouseSourceDevice\n                        axis: MouseDevice.Y\n                    }\n                ]\n            }\n        ]\n    }\n}\n"
  },
  {
    "path": "meshroom/ui/qml/Viewer3D/DepthMapLoader.qml",
    "content": "import DepthMapEntity 2.1\n\n/**\n * Support for Depth Map files (EXR) in Qt3d.\n * Create this component dynamically to test for DepthMapEntity plugin availability.\n */\n\nDepthMapEntity {\n    id: root\n\n    pointSize: Viewer3DSettings.pointSize * (Viewer3DSettings.fixedPointSize ? 1.0 : 0.001)\n    // Map render modes to custom visualization modes\n    displayMode: Viewer3DSettings.renderMode == 1 ? DepthMapEntity.Points : DepthMapEntity.Triangles\n    displayColor: Viewer3DSettings.renderMode == 2\n}\n"
  },
  {
    "path": "meshroom/ui/qml/Viewer3D/EntityWithGizmo.qml",
    "content": "import Qt3D.Core 2.6\nimport Qt3D.Render 2.6\nimport Qt3D.Input 2.6\nimport Qt3D.Extras 2.15\nimport Qt3D.Logic 2.6\nimport QtQuick\n\n/**\n * Wrapper for TransformGizmo.\n * Must be instantiated to control an other entity.\n * The goal is to instantiate the other entity inside this wrapper to gather the object and the gizmo.\n * objectTranform is the component the other entity should use as a Transform.\n */\n\nEntity {\n    id: root\n    property DefaultCameraController sceneCameraController\n    property Layer frontLayerComponent\n    property var window\n    property alias uniformScale: transformGizmo.uniformScale  // By default, if not set, the value is: false\n    property TransformGizmo transformGizmo: TransformGizmo {\n        id: transformGizmo\n        camera: root.camera\n        windowSize: root.windowSize\n        frontLayerComponent: root.frontLayerComponent\n        window: root.window\n\n        onPickedChanged: function(pressed) {\n            sceneCameraController.loseMouseFocus = pressed  // Notify the camera if the transform takes/releases the focus\n        }\n    }\n\n    readonly property Camera camera : sceneCameraController.camera\n    readonly property var windowSize: sceneCameraController.windowSize\n    readonly property alias objectTransform : transformGizmo.objectTransform  // The Transform the object should use\n}"
  },
  {
    "path": "meshroom/ui/qml/Viewer3D/EnvironmentMapEntity.qml",
    "content": "import QtQuick\nimport Qt3D.Core 2.6\nimport Qt3D.Render 2.6\nimport Qt3D.Extras 2.15\n\n/**\n * EnvironmentMap maps an equirectangular image on a Sphere.\n * The 'position' property can be used to virually attach it to a camera\n * and get the impression of an environment at an infinite distance.\n */\n\nEntity {\n    id: root\n\n    /// Source of the equirectangular image\n    property url source\n    /// Radius of the sphere\n    property alias radius: sphereMesh.radius\n    /// Number of slices of the sphere\n    property alias slices: sphereMesh.slices\n    /// Number of rings of the sphere\n    property alias rings: sphereMesh.rings\n    /// Position of the sphere\n    property alias position: transform.translation\n    /// Texture loading status\n    property alias status: textureLoader.status\n\n    components: [\n        SphereMesh {\n            id: sphereMesh\n            radius: 1000\n            slices: 50\n            rings: 50\n        },\n        Transform {\n            id: transform\n            translation: root.position\n        },\n        DiffuseMapMaterial {\n            ambient: \"#FFF\"\n            shininess: 0\n            specular: \"#000\"\n            diffuse: TextureLoader {\n                id: textureLoader\n                magnificationFilter: Texture.Linear\n                mirrored: true\n                source: root.source\n            }\n        }\n    ]\n}\n"
  },
  {
    "path": "meshroom/ui/qml/Viewer3D/Grid3D.qml",
    "content": "import QtQuick\nimport Qt3D.Core 2.6\nimport Qt3D.Render 2.6\nimport Qt3D.Extras 2.15\n\n// Grid\nEntity {\n    id: gridEntity\n    components: [\n        GeometryRenderer {\n            primitiveType: GeometryRenderer.Lines\n            geometry: Geometry {\n                Attribute {\n                    id: gridPosition\n                    attributeType: Attribute.VertexAttribute\n                    vertexBaseType: Attribute.Float\n                    vertexSize: 3\n                    count: 0\n                    name: defaultPositionAttributeName\n                    buffer: Buffer {\n                        data: {\n                            function buildGrid(first, last, offset, attribute) {\n                                var vertexCount = (((last - first) / offset) + 1) * 4\n                                var f32 = new Float32Array(vertexCount * 3)\n                                for (var id = 0, i = first; i <= last; i += offset, id++) {\n                                    f32[12 * id] = i\n                                    f32[12 * id + 1] = 0.0\n                                    f32[12 * id + 2] = first\n\n                                    f32[12 * id + 3] = i\n                                    f32[12 * id + 4] = 0.0\n                                    f32[12 * id + 5] = last\n\n                                    f32[12 * id + 6] = first\n                                    f32[12 * id + 7] = 0.0\n                                    f32[12 * id + 8] = i\n\n                                    f32[12 * id + 9] = last\n                                    f32[12 * id + 10] = 0.0\n                                    f32[12 * id + 11] = i\n                                }\n                                attribute.count = vertexCount\n                                return f32\n                            }\n                            return buildGrid(-12, 12, 1, gridPosition)\n                        }\n                    }\n                }\n                boundingVolumePositionAttribute: gridPosition\n            }\n        },\n        PhongMaterial {\n            ambient: \"#FFF\"\n            diffuse: \"#222\"\n            specular: diffuse\n            shininess: 0\n        }\n    ]\n}\n"
  },
  {
    "path": "meshroom/ui/qml/Viewer3D/ImageOverlay.qml",
    "content": "import QtQuick\nimport QtQuick.Layouts\n\n/**\n * ImageOverlay enables to display a Viewpoint image on top of a 3D View.\n * It takes the principal point correction into account and handle image ratio to\n * correctly fit or crop according to original image ratio and parent Item ratio.\n */\n\nItem {\n    id: root\n\n    /// The URL of the image to display\n    property alias source: image.source\n    /// Source image ratio\n    property real imageRatio: 1.0\n    /// Principal Point correction as UV coordinates offset\n    property alias uvCenterOffset: shader.uvCenterOffset\n    /// Whether to display the frame around the image\n    property bool showFrame\n    /// Opacity of the image\n    property alias imageOpacity: shader.opacity\n\n    implicitWidth: 300\n    implicitHeight: 300\n\n    // Display frame\n    RowLayout {\n        id: frameBG\n        spacing: 1\n        anchors.fill: parent\n        visible: root.showFrame && image.status === Image.Ready\n        Rectangle {\n            color: \"black\"\n            opacity: 0.5\n            Layout.fillWidth: true\n            Layout.fillHeight: true\n        }\n        Item {\n            Layout.preferredHeight: image.paintedHeight\n            Layout.preferredWidth: image.paintedWidth\n        }\n        Rectangle {\n            color: \"black\"\n            opacity: 0.5\n            Layout.fillWidth: true\n            Layout.fillHeight: true\n        }\n    }\n\n    Image {\n        id: image\n        asynchronous: true\n        smooth: false\n        anchors.fill: parent\n        visible: false\n        // Preserve aspect fit while display ratio is aligned with image ratio, crop otherwise\n        fillMode: width / height >= imageRatio ? Image.PreserveAspectFit : Image.PreserveAspectCrop\n        autoTransform: true\n    }\n\n\n    // Custom shader for displaying undistorted images with principal point correction\n    ShaderEffect {\n        id: shader\n        anchors.centerIn: parent\n        visible: image.status === Image.Ready\n        width: image.paintedWidth\n        height: image.paintedHeight\n        property variant src: image\n        property variant uvCenterOffset\n\n        fragmentShader: \"qrc:/shaders/ImageOverlay.frag.qsb\"\n    }\n}\n"
  },
  {
    "path": "meshroom/ui/qml/Viewer3D/Inspector3D.qml",
    "content": "import QtQuick\nimport QtQuick.Controls\nimport QtQuick.Layouts\nimport Qt3D.Core 2.6\nimport Qt3D.Render 2.6\n\nimport Controls 1.0\nimport MaterialIcons 2.2\nimport Utils 1.0\n\nFloatingPane {\n    id: root\n\n    implicitWidth: 200\n\n    property int renderMode: 2\n    property Grid3D grid: null\n    property MediaLibrary mediaLibrary\n    property Camera camera\n    property var uigraph: null\n\n    signal mediaFocusRequest(var index)\n    signal mediaRemoveRequest(var index)\n    signal nodeActivated(var node)\n\n    padding: 0\n\n    MouseArea {\n        anchors.fill: parent\n        onWheel: function(wheel) {\n            wheel.accepted = true\n        }\n    }\n\n    ColumnLayout {\n        anchors.fill: parent\n        spacing: 4\n\n        ExpandableGroup {\n            id: displayGroup\n            Layout.fillWidth: true\n            title: \"DISPLAY\"\n\n            GridLayout {\n                width: parent.width\n                columns: 2\n                columnSpacing: 6\n                rowSpacing: 3\n                Flow {\n                    Layout.columnSpan: 2\n                    Layout.fillWidth: true\n                    visible: displayGroup.expanded\n                    spacing: 1\n                    MaterialToolButton {\n                        text: MaterialIcons.grid_on\n                        ToolTip.text: \"Display Grid\"\n                        checked: Viewer3DSettings.displayGrid\n                        onClicked: Viewer3DSettings.displayGrid = !Viewer3DSettings.displayGrid\n                    }\n                    MaterialToolButton {\n                        text: MaterialIcons.adjust\n                        checked: Viewer3DSettings.displayGizmo\n                        ToolTip.text: \"Display Trackball\"\n                        onClicked: Viewer3DSettings.displayGizmo = !Viewer3DSettings.displayGizmo\n                    }\n                    MaterialToolButton {\n                        text: MaterialIcons.call_merge\n                        ToolTip.text: \"Display Origin\"\n                        checked: Viewer3DSettings.displayOrigin\n                        onClicked: Viewer3DSettings.displayOrigin = !Viewer3DSettings.displayOrigin\n                    }\n                    MaterialToolButton {\n                        text: MaterialIcons.light_mode\n                        ToolTip.text: \"Display Light Controller\"\n                        checked: Viewer3DSettings.displayLightController\n                        onClicked: Viewer3DSettings.displayLightController = !Viewer3DSettings.displayLightController\n                    }\n                }\n                MaterialLabel {\n                    text: MaterialIcons.grain\n                    padding: 2\n                    visible: displayGroup.expanded\n                }\n                RowLayout {\n                    visible: displayGroup.expanded\n                    Slider {\n                        Layout.fillWidth: true; from: 0; to: 5; stepSize: 0.001\n                        value: Viewer3DSettings.pointSize\n                        onValueChanged: Viewer3DSettings.pointSize = value\n                        ToolTip.text: \"Point Size: \" + value.toFixed(2)\n                        ToolTip.visible: hovered || pressed\n                        ToolTip.delay: 150\n                    }\n                    MaterialToolButton {\n                        text: MaterialIcons.center_focus_strong\n                        ToolTip.text: \"Fixed Point Size\"\n                        font.pointSize: 10\n                        padding: 3\n                        checked: Viewer3DSettings.fixedPointSize\n                        onClicked: Viewer3DSettings.fixedPointSize = !Viewer3DSettings.fixedPointSize\n                    }\n\n                }\n                MaterialLabel {\n                    text: MaterialIcons.videocam\n                    padding: 2\n                    visible: displayGroup.expanded\n                }\n                Slider {\n                    visible: displayGroup.expanded\n                    value: Viewer3DSettings.cameraScale\n                    from: 0\n                    to: 2\n                    stepSize: 0.01\n                    Layout.fillWidth: true\n                    padding: 0\n                    onMoved: Viewer3DSettings.cameraScale = value\n                    ToolTip.text: \"Camera Scale: \" + value.toFixed(2)\n                    ToolTip.visible: hovered || pressed\n                    ToolTip.delay: 150\n                }\n            }\n        }\n\n        ExpandableGroup {\n            id: cameraGroup\n            Layout.fillWidth: true\n            title: \"CAMERA\"\n\n            ColumnLayout {\n                width: parent.width\n\n                // Image/Camera synchronization\n                Flow {\n                    Layout.fillWidth: true\n                    visible: cameraGroup.expanded\n                    spacing: 2\n\n                    // Synchronization\n                    MaterialToolButton {\n                        id: syncViewpointCamera\n                        enabled: _currentScene && mediaLibrary.count > 0 ? _currentScene.sfmReport : false\n                        text: MaterialIcons.linked_camera\n                        ToolTip.text: \"View Through The Active Camera\"\n                        checkable: true\n                        checked: enabled && Viewer3DSettings.syncViewpointCamera\n                        onCheckedChanged: Viewer3DSettings.syncViewpointCamera = !Viewer3DSettings.syncViewpointCamera\n                    }\n\n                    // Image Overlay controls\n                    RowLayout {\n                        visible: syncViewpointCamera.enabled && Viewer3DSettings.syncViewpointCamera\n                        spacing: 2\n                        // Activation\n                        MaterialToolButton {\n                            text: MaterialIcons.image\n                            ToolTip.text: \"Image Overlay\"\n                            checked: Viewer3DSettings.viewpointImageOverlay\n                            onClicked: Viewer3DSettings.viewpointImageOverlay = !Viewer3DSettings.viewpointImageOverlay\n                        }\n                        // Opacity\n                        Slider {\n                            visible: Viewer3DSettings.showViewpointImageOverlay\n                            implicitWidth: 60\n                            from: 0\n                            to: 100\n                            value: Viewer3DSettings.viewpointImageOverlayOpacity * 100\n                            onValueChanged: Viewer3DSettings.viewpointImageOverlayOpacity = value / 100\n                            ToolTip.text: \"Image Opacity: \" + Viewer3DSettings.viewpointImageOverlayOpacity.toFixed(2)\n                            ToolTip.visible: hovered || pressed\n                            ToolTip.delay: 100\n                        }\n                    }\n                    // Image Frame control\n                    MaterialToolButton {\n                        visible: syncViewpointCamera.enabled && Viewer3DSettings.showViewpointImageOverlay\n                        enabled: Viewer3DSettings.syncViewpointCamera\n                        text: MaterialIcons.crop_free\n                        ToolTip.text: \"Frame Overlay\"\n                        checked: Viewer3DSettings.viewpointImageFrame\n                        onClicked: Viewer3DSettings.viewpointImageFrame = !Viewer3DSettings.viewpointImageFrame\n                    }\n                }\n\n                ColumnLayout {\n                    Layout.fillWidth: true\n                    spacing: 2\n                    visible: cameraGroup.expanded\n\n                    RowLayout {\n                        Layout.fillHeight: true\n                        spacing: 2\n\n                        MaterialToolButton {\n                            id: resectionIdButton\n                            text: MaterialIcons.switch_video\n                            ToolTip.text: \"Timeline Of Camera Reconstruction Groups\"\n                            ToolTip.visible: hovered\n                            enabled: Viewer3DSettings.resectionIdCount\n                            checked: enabled && Viewer3DSettings.displayResectionIds\n                            onClicked: {\n                                Viewer3DSettings.displayResectionIds = !Viewer3DSettings.displayResectionIds\n                                Viewer3DSettings.resectionId = Viewer3DSettings.resectionIdCount\n                                resectionIdSlider.value = Viewer3DSettings.resectionId\n                            }\n\n                            onEnabledChanged: {\n                                Viewer3DSettings.resectionId = Viewer3DSettings.resectionIdCount\n                                resectionIdSlider.value = Viewer3DSettings.resectionId\n                                if (!enabled) {\n                                    Viewer3DSettings.displayResectionIds = false\n                                }\n                            }\n                        }\n\n                        Slider {\n                            id: resectionIdSlider\n                            value: Viewer3DSettings.resectionId\n                            from: 0\n                            to: Viewer3DSettings.resectionIdCount\n                            stepSize: 1\n                            onMoved: Viewer3DSettings.resectionId = value\n                            Layout.fillWidth: true\n                            leftPadding: 2\n                            rightPadding: 2\n                            visible: Viewer3DSettings.displayResectionIds\n                        }\n\n                        Label {\n                            text: resectionIdSlider.value + \"/\" + Viewer3DSettings.resectionIdCount\n                            color: palette.text\n                            visible: Viewer3DSettings.displayResectionIds\n                        }\n                    }\n\n                    RowLayout {\n                        spacing: 10\n                        Layout.fillWidth: true\n                        Layout.margins: 2\n                        Layout.alignment: Qt.AlignHCenter | Qt.AlignVCenter\n\n                        MaterialToolLabel {\n                            iconText: MaterialIcons.stop\n                            label.text: {\n                                var id = undefined\n                                // Ensure there are entries in resectionGroups and a valid resectionId before accessing anything\n                                if (Viewer3DSettings.resectionId !== undefined && Viewer3DSettings.resectionGroups &&\n                                    Viewer3DSettings.resectionGroups.length > 0)\n                                    id = Math.min(Viewer3DSettings.resectionId, Viewer3DSettings.resectionIdCount)\n                                if (id !== undefined && Viewer3DSettings.resectionGroups[id] !== undefined)\n                                    return Viewer3DSettings.resectionGroups[id]\n                                return 0\n\n                            }\n                            labelIconColor: palette.text\n                            ToolTip.text: \"Number Of Cameras In Current Resection Group\"\n                            visible: Viewer3DSettings.displayResectionIds\n                        }\n\n                        MaterialToolLabel {\n                            iconText: MaterialIcons.auto_awesome_motion\n                            label.text: {\n                                let currentCameras = 0\n                                if (Viewer3DSettings.resectionGroups) {\n                                    for (let i = 0; i <= Viewer3DSettings.resectionIdCount; i++) {\n                                        if (i <= Viewer3DSettings.resectionId)\n                                            currentCameras += Viewer3DSettings.resectionGroups[i]\n                                    }\n                                }\n\n                                return currentCameras\n                            }\n                            labelIconColor: palette.text\n                            ToolTip.text: \"Number Of Cumulated Cameras\"\n                            visible: Viewer3DSettings.displayResectionIds\n                        }\n\n                        MaterialToolLabel {\n                            iconText: MaterialIcons.videocam\n                            label.text: {\n                                let totalCameras = 0\n                                if (Viewer3DSettings.resectionGroups) {\n                                    for (let i = 0; i <= Viewer3DSettings.resectionIdCount; i++) {\n                                        totalCameras += Viewer3DSettings.resectionGroups[i]\n                                    }\n                                }\n\n                                return totalCameras\n                            }\n                            labelIconColor: palette.text\n                            ToolTip.text: \"Total Number Of Cameras\"\n                            visible: Viewer3DSettings.displayResectionIds\n                        }\n                    }\n                }\n            }\n        }\n\n        // 3D Scene content\n        Group {\n            title: \"SCENE\"\n            Layout.fillWidth: true\n            Layout.fillHeight: true\n            sidePadding: 0\n\n            toolBarContent: MaterialToolButton {\n                id: infoButton\n                ToolTip.text: \"Media Info\"\n                text: MaterialIcons.info_outline\n                font.pointSize: 10\n                implicitHeight: parent.height\n                checkable: true\n                checked: true\n            }\n\n            ColumnLayout {\n                anchors.fill: parent\n\n                SearchBar {\n                    id: searchBar\n                    Layout.minimumWidth: 150\n                    Layout.fillWidth: true\n                    Layout.rightMargin: 10\n                    Layout.leftMargin: 10\n                }\n\n                ListView {\n                    id: mediaListView\n                    Layout.fillHeight: true\n                    Layout.fillWidth: true\n                    clip: true\n                    spacing: 4\n\n                    ScrollBar.vertical: MScrollBar { id: scrollBar }\n\n                    onCountChanged: {\n                        if (mediaListView.count === 0) {\n                            Viewer3DSettings.resectionIdCount = 0\n                        }\n                    }\n\n                    currentIndex: -1\n\n                    Connections {\n                        target: uigraph\n                        function onSelectedNodeChanged() {\n                            mediaListView.currentIndex = -1\n                        }\n                    }\n\n                    Connections {\n                        target: mediaLibrary\n                        function onLoadRequest(idx) {\n                            mediaListView.positionViewAtIndex(idx, ListView.Visible)\n                        }\n                    }\n\n                    model: SortFilterDelegateModel {\n                        model: mediaLibrary.model\n                        sortRole: \"\"\n                        filters: [{role: \"label\", value: searchBar.text}]\n                        delegate: MouseArea {\n                            id: mediaDelegate\n                            // Add mediaLibrary.count in the binding to ensure 'entity'\n                            // is re-evaluated when mediaLibrary delegates are modified\n                            property bool loading: model.status === SceneLoader.Loading\n                            property bool hovered: model.attribute ? (uigraph ? uigraph.hoveredNode === model.attribute.node : false) : containsMouse\n                            property bool isSelectedNode: model.attribute ? (uigraph ? uigraph.selectedNode === model.attribute.node : false) : false\n\n                            onIsSelectedNodeChanged: updateCurrentIndex()\n\n                            function updateCurrentIndex() {\n                                if (isSelectedNode) {\n                                    mediaListView.currentIndex = index\n                                }\n\n                                // If the index is updated, and the resection ID count is available, update every resection-related variable:\n                                // this covers the changes of index that occur when a node whose output is already loaded in the 3D viewer is\n                                // clicked/double-clicked, and when the active entry is removed from the list.\n                                if (model.resectionIdCount) {\n                                    Viewer3DSettings.resectionIdCount = model.resectionIdCount\n                                    Viewer3DSettings.resectionGroups = model.resectionGroups\n                                    Viewer3DSettings.resectionId = model.resectionId\n                                    resectionIdSlider.value = model.resectionId\n                                }\n                            }\n\n                            height: childrenRect.height\n                            width: {\n                                if (parent != null)\n                                    return parent.width - scrollBar.width\n                                return undefined\n                            }\n\n                            hoverEnabled: true\n                            onEntered: {\n                                if (model.attribute)\n                                    uigraph.hoveredNode = model.attribute.node\n                            }\n                            onExited: {\n                                if (model.attribute)\n                                    uigraph.hoveredNode = null\n                            }\n                            onClicked: function(mouse) {\n                                if (model.attribute)\n                                    uigraph.selectedNode = model.attribute.node\n                                else\n                                    uigraph.selectedNode = null\n                                if (mouse.button == Qt.RightButton)\n                                    contextMenu.popup()\n                                mediaListView.currentIndex = index\n\n                                // Update the resection ID-related objects based on the active model\n                                Viewer3DSettings.resectionIdCount = model.resectionIdCount\n                                Viewer3DSettings.resectionGroups = model.resectionGroups\n                                Viewer3DSettings.resectionId = model.resectionId\n                                resectionIdSlider.value = model.resectionId\n                            }\n                            onDoubleClicked: {\n                                model.visible = true;\n                                nodeActivated(model.attribute.node);\n                            }\n\n                            Connections {\n                                target: resectionIdSlider\n                                function onValueChanged() {\n                                    model.resectionId = resectionIdSlider.value\n                                }\n                            }\n\n                            RowLayout {\n                                width: parent.width\n                                spacing: 2\n\n                                property string src: model.source\n                                onSrcChanged: focusAnim.restart()\n\n                                Connections {\n                                    target: mediaListView\n                                    function onCountChanged() {\n                                        mediaDelegate.updateCurrentIndex()\n                                    }\n                                }\n\n                                // Current/selected element indicator\n                                Rectangle {\n                                    Layout.fillHeight: true\n                                    width: 2\n                                    color: {\n                                        if (mediaListView.currentIndex == index || mediaDelegate.isSelectedNode)\n                                            return label.palette.highlight;\n                                        if (mediaDelegate.hovered)\n                                            return Qt.darker(label.palette.highlight, 1.5);\n                                        return \"transparent\";\n                                    }\n                                }\n\n                                // Media visibility/loading control\n                                MaterialToolButton {\n                                    Layout.alignment: Qt.AlignTop\n                                    Layout.fillHeight: true\n                                    text: model.visible ? MaterialIcons.visibility : MaterialIcons.visibility_off\n                                    font.pointSize: 10\n                                    ToolTip.text: model.visible ? \"Hide\" : model.requested ? \"Show\" : model.valid ? \"Load and Show\" : \"Load and Show when Available\"\n                                    flat: true\n                                    opacity: model.visible ? 1.0 : 0.6\n                                    onClicked: {\n                                        if (hoverArea.modifiers & Qt.ControlModifier)\n                                            mediaLibrary.solo(index);\n                                        else\n                                            model.visible = !model.visible\n                                    }\n                                    // Handle modifiers on button click\n                                    MouseArea {\n                                        id: hoverArea\n                                        property int modifiers\n                                        anchors.fill: parent\n                                        hoverEnabled: true\n                                        onPositionChanged: function(mouse) {\n                                            modifiers = mouse.modifiers\n                                        }\n                                        onExited: modifiers = Qt.NoModifier\n                                        onPressed: function(mouse) {\n                                            modifiers = mouse.modifiers;\n                                            mouse.accepted = false;\n                                        }\n                                    }\n                                }\n\n                                // BoundingBox visibility (if meshing node)\n                                MaterialToolButton {\n                                    visible: model.hasBoundingBox\n                                    enabled: model.visible\n                                    Layout.alignment: Qt.AlignTop\n                                    Layout.fillHeight: true\n                                    text: MaterialIcons.transform_\n                                    font.pointSize: 10\n                                    ToolTip.text: model.displayBoundingBox ? \"Hide BBox\" : \"Show BBox\"\n                                    flat: true\n                                    opacity: model.visible ? (model.displayBoundingBox ? 1.0 : 0.6) : 0.6\n                                    onClicked: {\n                                        model.displayBoundingBox = !model.displayBoundingBox\n                                    }\n                                }\n\n                                // Transform visibility (if SfMTransform node)\n                                MaterialToolButton {\n                                    visible: model.hasTransform\n                                    enabled: model.visible\n                                    Layout.alignment: Qt.AlignTop\n                                    Layout.fillHeight: true\n                                    text: MaterialIcons._3d_rotation\n                                    font.pointSize: 10\n                                    ToolTip.text: model.displayTransform ? \"Hide Gizmo\" : \"Show Gizmo\"\n                                    flat: true\n                                    opacity: model.visible ? (model.displayTransform ? 1.0 : 0.6) : 0.6\n                                    onClicked: {\n                                        model.displayTransform = !model.displayTransform\n                                    }\n                                }\n\n                                // Media label and info\n                                Item {\n                                    implicitHeight: childrenRect.height\n                                    Layout.fillWidth: true\n                                    Layout.alignment: Qt.AlignTop\n                                    ColumnLayout {\n                                        id: centralLayout\n                                        width: parent.width\n                                        spacing: 1\n\n                                        Label {\n                                            id: label\n                                            Layout.fillWidth: true\n                                            leftPadding: 0\n                                            rightPadding: 0\n                                            topPadding: 3\n                                            bottomPadding: topPadding\n                                            text: model.label\n                                            color: palette.text\n                                            opacity: model.valid ? 1.0 : 0.6\n                                            elide: Text.ElideMiddle\n                                            font.weight: mediaListView.currentIndex === index ? Font.DemiBold : Font.Normal\n                                            background: Rectangle {\n                                                Connections {\n                                                    target: mediaLibrary\n                                                    function onLoadRequest(idx) {\n                                                        if (idx === index)\n                                                            focusAnim.restart()\n                                                    }\n                                                }\n                                                ColorAnimation on color {\n                                                    id: focusAnim\n                                                    from: label.palette.highlight\n                                                    to: \"transparent\"\n                                                    duration: 2000\n                                                }\n                                            }\n                                        }\n                                        Item {\n                                            visible: infoButton.checked\n                                            Layout.fillWidth: true\n                                            implicitHeight: childrenRect.height\n                                            Flow {\n                                                width: parent.width\n                                                spacing: 4\n                                                visible: model.status === SceneLoader.Ready\n                                                RowLayout {\n                                                    spacing: 1\n                                                    visible: model.vertexCount\n                                                    MaterialLabel { text: MaterialIcons.grain }\n                                                    Label {\n                                                        text: Format.intToString(model.vertexCount)\n                                                        color: palette.text\n                                                    }\n                                                }\n                                                RowLayout {\n                                                    spacing: 1\n                                                    visible: model.faceCount\n                                                    MaterialLabel { text: MaterialIcons.details; rotation: -180 }\n                                                    Label {\n                                                        text: Format.intToString(model.faceCount)\n                                                        color: palette.text\n                                                    }\n                                                }\n                                                RowLayout {\n                                                    spacing: 1\n                                                    visible: model.cameraCount\n                                                    MaterialLabel { text: MaterialIcons.videocam }\n                                                    Label {\n                                                        text: model.cameraCount\n                                                        color: palette.text\n                                                    }\n                                                }\n                                                RowLayout {\n                                                    spacing: 1\n                                                    visible: model.textureCount\n                                                    MaterialLabel { text: MaterialIcons.texture }\n                                                    Label {\n                                                        text: model.textureCount\n                                                        color: palette.text\n                                                    }\n                                                }\n                                            }\n                                        }\n                                    }\n\n                                    Menu {\n                                        id: contextMenu\n                                        MenuItem {\n                                            text: \"Open Containing Folder\"\n                                            enabled: model.valid\n                                            onTriggered: Qt.openUrlExternally(Filepath.dirname(model.source))\n                                        }\n                                        MenuItem {\n                                            text: \"Copy Path\"\n                                            onTriggered: Clipboard.setText(Filepath.normpath(model.source))\n                                        }\n                                        MenuSeparator {}\n                                        MenuItem {\n                                            text: model.requested ? \"Unload Media\" : \"Load Media\"\n                                            enabled: model.valid\n                                            onTriggered: model.requested = !model.requested\n                                        }\n                                    }\n                                }\n\n                                // Remove media from library button\n                                MaterialToolButton {\n                                    id: removeButton\n                                    Layout.alignment: Qt.AlignTop\n                                    Layout.fillHeight: true\n\n                                    visible: !loading && mediaDelegate.containsMouse\n                                    text: MaterialIcons.clear\n                                    font.pointSize: 10\n\n                                    ToolTip.text: \"Remove\"\n                                    ToolTip.delay: 500\n                                    onClicked: mediaLibrary.remove(index)\n                                }\n\n                                // Media loading indicator\n                                BusyIndicator {\n                                    visible: loading\n                                    running: visible\n                                    padding: removeButton.padding\n                                    implicitHeight: implicitWidth\n                                    implicitWidth: removeButton.width\n                                }\n                            }\n                        }\n                    }\n                }\n            }\n        }\n    }\n}\n"
  },
  {
    "path": "meshroom/ui/qml/Viewer3D/Locator3D.qml",
    "content": "import QtQuick\nimport Qt3D.Core 2.6\nimport Qt3D.Render 2.6\nimport Qt3D.Extras 2.15\n\nimport Utils 1.0\n\n// Locator\nEntity {\n    id: locatorEntity\n    components: [\n        GeometryRenderer {\n            primitiveType: GeometryRenderer.Lines\n            geometry: Geometry {\n                Attribute {\n                    id: locatorPosition\n                    attributeType: Attribute.VertexAttribute\n                    vertexBaseType: Attribute.Float\n                    vertexSize: 3\n                    count: 6\n                    name: defaultPositionAttributeName\n                    buffer: Buffer {\n                        data: new Float32Array([\n                            0.0, 0.001, 0.0,\n                            1.0, 0.001, 0.0,\n                            0.0, 0.001, 0.0,\n                            0.0, 1.001, 0.0,\n                            0.0, 0.001, 0.0,\n                            0.0, 0.001, 1.0\n                            ])\n                    }\n                }\n                Attribute {\n                    attributeType: Attribute.VertexAttribute\n                    vertexBaseType: Attribute.Float\n                    vertexSize: 3\n                    count: 6\n                    name: defaultColorAttributeName\n                    buffer: Buffer {\n                        data: new Float32Array([\n                            Colors.red.r, Colors.red.g, Colors.red.b,\n                            Colors.red.r, Colors.red.g, Colors.red.b,\n                            Colors.green.r, Colors.green.g, Colors.green.b,\n                            Colors.green.r, Colors.green.g, Colors.green.b,\n                            Colors.blue.r, Colors.blue.g, Colors.blue.b,\n                            Colors.blue.r, Colors.blue.g, Colors.blue.b\n                            ])\n                    }\n                }\n                boundingVolumePositionAttribute: locatorPosition\n            }\n        },\n        PerVertexColorMaterial {},\n        Transform { id: locatorTransform }\n    ]\n}\n"
  },
  {
    "path": "meshroom/ui/qml/Viewer3D/MaterialSwitcher.qml",
    "content": "import Qt3D.Core 2.6\nimport Qt3D.Render 2.6\nimport Qt3D.Input 2.6\nimport Qt3D.Extras 2.15\nimport QtQuick\n\nimport Utils 1.0\nimport \"Materials\"\n\n/**\n * MaterialSwitcher is an Entity that can change its parent's material\n * by setting the 'mode' property.\n */\n\nEntity {\n    id: root\n    objectName: \"MaterialSwitcher\"\n\n    property int mode: 2\n    property string diffuseMap: \"\"\n    property color ambient: \"#AAA\"\n    property real shininess\n    property color specular\n    property color diffuseColor: \"#AAA\"\n\n    readonly property alias activeMaterial: m.material\n\n    QtObject {\n        id: m\n        property Material material\n        onMaterialChanged: {\n            // Remove previous material(s)\n            removeComponentsByType(parent, \"Material\")\n            Scene3DHelper.addComponent(root.parent, material)\n        }\n    }\n\n    function removeComponentsByType(entity, type)\n    {\n        if (!entity)\n            return\n        for (var i = 0; i < entity.components.length; ++i) {\n            if (entity.components[i].toString().indexOf(type) !== -1) {\n                Scene3DHelper.removeComponent(entity, entity.components[i])\n            }\n        }\n    }\n\n    StateGroup {\n        id: modeState\n        state: Viewer3DSettings.renderModes[mode].name\n\n        states: [\n            State {\n                name: \"Solid\"\n                PropertyChanges { target: m; material: solid }\n            },\n            State {\n                name: \"Wireframe\"\n                PropertyChanges { target: m; material: wireframe }\n            },\n            State {\n                name: \"Textured\"\n                PropertyChanges {\n                    target: m;\n                    // \"textured\" material resolution order: diffuse map > vertex color data >  no color info\n                    material: diffuseMap ? textured : (Scene3DHelper.vertexColorCount(root.parent) ? colored : solid)\n                }\n            },\n            State {\n                name: \"Spherical Harmonics\"\n                PropertyChanges { target: m; material: shMaterial }\n            }\n        ]\n    }\n\n    // Solid and Textured modes could and should be using the same material\n    // but get random shader errors (No shader program found for DNA)\n    // when toggling between a color and a texture for the diffuse property\n\n    DiffuseSpecularMaterial {\n        id: solid\n        objectName: \"SolidMaterial\"\n        ambient: root.ambient\n        shininess: root.shininess\n        specular: root.specular\n        diffuse: root.diffuseColor\n    }\n\n    PerVertexColorMaterial {\n        id: colored\n        objectName: \"VertexColorMaterial\"\n    }\n\n    DiffuseMapMaterial {\n        id: textured\n        objectName: \"TexturedMaterial\"\n        ambient: root.ambient\n        shininess: root.shininess\n        specular: root.specular\n        diffuse: TextureLoader {\n            magnificationFilter: Texture.Linear\n            mirrored: false\n            source: diffuseMap\n        }\n    }\n\n    WireframeMaterial {\n        id: wireframe\n        objectName: \"WireframeMaterial\"\n        effect: WireframeEffect {}\n    }\n\n    SphericalHarmonicsMaterial {\n        id: shMaterial\n        objectName: \"SphericalHarmonicsMaterial\"\n        effect: SphericalHarmonicsEffect {}\n        shlSource: Filepath.stringToUrl(Viewer3DSettings.shlFile)\n        displayNormals: Viewer3DSettings.displayNormals\n    }\n}\n"
  },
  {
    "path": "meshroom/ui/qml/Viewer3D/Materials/SphericalHarmonicsEffect.qml",
    "content": "import Qt3D.Core 2.6\nimport Qt3D.Render 2.6\n\nEffect {\n    id: root\n\n\n    parameters: [\n        Parameter { name: \"shCoeffs[0]\"; value: [] },\n        Parameter { name: \"displayNormals\"; value: false }\n    ]\n\n    techniques: [\n        Technique {\n            graphicsApiFilter {\n                api: GraphicsApiFilter.RHI\n                profile: GraphicsApiFilter.CoreProfile\n                majorVersion: 1\n                minorVersion: 0\n            }\n\n\n            filterKeys: [ FilterKey { name: \"renderingStyle\"; value: \"forward\" } ]\n\n            renderPasses: [\n                RenderPass {\n                    shaderProgram: ShaderProgram {\n                        vertexShaderCode:   loadSource(Qt.resolvedUrl(\"shaders/SphericalHarmonics.vert\"))\n                        fragmentShaderCode: loadSource(Qt.resolvedUrl(\"shaders/SphericalHarmonics.frag\"))\n\n                    }\n                }\n            ]\n        }\n    ]\n}\n"
  },
  {
    "path": "meshroom/ui/qml/Viewer3D/Materials/SphericalHarmonicsMaterial.qml",
    "content": "import Qt3D.Core 2.6\nimport Qt3D.Render 2.6\n\nimport Utils 1.0\n\nMaterial {\n    id: root\n\n    /// Source file containing coefficients\n    property url shlSource\n    /// Spherical Harmonics coefficients (array of 9 vector3d)\n    property var coefficients: noCoeffs\n    /// Whether to display normals instead of SH\n    property bool displayNormals: false\n\n    // Default coefficients (uniform magenta)\n    readonly property var noCoeffs: [\n        Qt.vector3d(0.0, 0.0, 0.0),\n        Qt.vector3d(0.0, 0.0, 0.0),\n        Qt.vector3d(0.0, 0.0, 0.0),\n        Qt.vector3d(1.0, 0.0, 1.0),\n        Qt.vector3d(0.0, 0.0, 0.0),\n        Qt.vector3d(0.0, 0.0, 0.0),\n        Qt.vector3d(0.0, 0.0, 0.0),\n        Qt.vector3d(0.0, 0.0, 0.0),\n        Qt.vector3d(0.0, 0.0, 0.0)\n    ]\n\n    effect: SphericalHarmonicsEffect {}\n\n    onShlSourceChanged: {\n        if (!shlSource) {\n            coefficients = noCoeffs\n            return\n        }\n\n        Request.get(Filepath.urlToString(shlSource), function(xhr) {\n            if (xhr.readyState === XMLHttpRequest.DONE) {\n                var coeffs = []\n                var lines = xhr.responseText.split(\"\\n\")\n                lines.forEach(function(l) {\n                    var lineCoeffs = []\n                    l.split(\" \").forEach(function(v) {\n                        if (v)\n                            lineCoeffs.push(v)\n                    })\n                    if (lineCoeffs.length == 3)\n                        coeffs.push(Qt.vector3d(lineCoeffs[0], lineCoeffs[1], lineCoeffs[2]))\n                })\n\n                if (coeffs.length == 9) {\n                    coefficients = coeffs\n                } else {\n                    console.warn(\"Invalid SHL file: \" + shlSource + \" with \" + coeffs.length + \" coefficients.\")\n                    coefficients = noCoeffs\n                }\n            }\n        })\n    }\n\n    parameters: [\n        Parameter { name: \"shCoeffs[0]\"; value: coefficients },\n        Parameter { name: \"displayNormals\"; value: displayNormals }\n    ]\n}\n"
  },
  {
    "path": "meshroom/ui/qml/Viewer3D/Materials/WireframeEffect.qml",
    "content": "import Qt3D.Core 2.6\nimport Qt3D.Render 2.6\n\nEffect {\n    id: root\n\n    parameters: [\n    ]\n\n    techniques: [\n        Technique {\n            graphicsApiFilter {\n                api: GraphicsApiFilter.RHI\n                profile: GraphicsApiFilter.CoreProfile\n                majorVersion: 1\n                minorVersion: 0\n            }\n\n            filterKeys: [ FilterKey { name: \"renderingStyle\"; value: \"forward\" } ]\n\n            parameters: [\n            ]\n\n            renderPasses: [\n                RenderPass {\n                    shaderProgram: ShaderProgram {\n                        vertexShaderCode:   loadSource(Qt.resolvedUrl(\"shaders/robustwireframe.vert\"))\n                        fragmentShaderCode: loadSource(Qt.resolvedUrl(\"shaders/robustwireframe.frag\"))\n                    }\n                }\n            ]\n        }\n    ]\n}\n"
  },
  {
    "path": "meshroom/ui/qml/Viewer3D/Materials/WireframeMaterial.qml",
    "content": "import Qt3D.Core 2.6\nimport Qt3D.Render 2.6\n\nMaterial {\n    id: root\n\n    effect: WireframeEffect {}\n\n    parameters: [\n    ]\n}\n"
  },
  {
    "path": "meshroom/ui/qml/Viewer3D/Materials/shaders/SphericalHarmonics.frag",
    "content": "#version 450 core\n\nlayout(location = 0) in vec3 normal;\nlayout(location = 0) out vec4 fragColor;\n\nlayout(std140, binding = 0) uniform qt3d_render_view_uniforms {\n    mat4 viewMatrix;\n    mat4 projectionMatrix;\n    mat4 uncorrectedProjectionMatrix;\n    mat4 clipCorrectionMatrix;\n    mat4 viewProjectionMatrix;\n    mat4 inverseViewMatrix;\n    mat4 inverseProjectionMatrix;\n    mat4 inverseViewProjectionMatrix;\n    mat4 viewportMatrix;\n    mat4 inverseViewportMatrix;\n    vec4 textureTransformMatrix;\n    vec3 eyePosition;\n    float aspectRatio;\n    float gamma;\n    float exposure;\n    float time;\n};\nlayout(std140, binding = 1) uniform qt3d_command_uniforms {\n    mat4 modelMatrix;\n    mat4 inverseModelMatrix;\n    mat4 modelViewMatrix;\n    mat3 modelNormalMatrix;\n    mat4 inverseModelViewMatrix;\n    mat4 mvp;\n    mat4 inverseModelViewProjectionMatrix;\n};\n\nlayout(std140, binding = 2) uniform input_uniforms {\n    vec3 shCoeffs[9];\n    bool displayNormals;\n};\n\nvec3 resolveSH_Opt(vec3 premulCoefficients[9], vec3 dir)\n{\n    vec3 result = premulCoefficients[0] * dir.x;\n    result += premulCoefficients[1] * dir.y;\n    result += premulCoefficients[2] * dir.z;\n    result += premulCoefficients[3];\n    vec3 dirSq = dir * dir;\n    result += premulCoefficients[4] * (dir.x * dir.y);\n    result += premulCoefficients[5] * (dir.x * dir.z);\n    result += premulCoefficients[6] * (dir.y * dir.z);\n    result += premulCoefficients[7] * (dirSq.x - dirSq.y);\n    result += premulCoefficients[8] * (3 * dirSq.z - 1);\n    return result;\n}\n\nvoid main()\n{\n    if(displayNormals) {\n        // Display normals mode\n        fragColor = vec4(normal, 1.0);\n    }\n    else {\n        // Calculate the color from spherical harmonics coeffs\n        fragColor = vec4(resolveSH_Opt(shCoeffs, normal), 1.0);\n    }\n}\n"
  },
  {
    "path": "meshroom/ui/qml/Viewer3D/Materials/shaders/SphericalHarmonics.vert",
    "content": "#version 450 core\n\nlayout(location = 0) in vec3 vertexPosition;\nlayout(location = 1) in vec3 vertexNormal;\n\nlayout(location = 0) out vec3 normal;\n\nlayout(std140, binding = 0) uniform qt3d_render_view_uniforms {\n    mat4 viewMatrix;\n    mat4 projectionMatrix;\n    mat4 uncorrectedProjectionMatrix;\n    mat4 clipCorrectionMatrix;\n    mat4 viewProjectionMatrix;\n    mat4 inverseViewMatrix;\n    mat4 inverseProjectionMatrix;\n    mat4 inverseViewProjectionMatrix;\n    mat4 viewportMatrix;\n    mat4 inverseViewportMatrix;\n    vec4 textureTransformMatrix;\n    vec3 eyePosition;\n    float aspectRatio;\n    float gamma;\n    float exposure;\n    float time;\n};\nlayout(std140, binding = 1) uniform qt3d_command_uniforms {\n    mat4 modelMatrix;\n    mat4 inverseModelMatrix;\n    mat4 modelViewMatrix;\n    mat3 modelNormalMatrix;\n    mat4 inverseModelViewMatrix;\n    mat4 mvp;\n    mat4 inverseModelViewProjectionMatrix;\n};\n\nvoid main()\n{\n    normal = vertexNormal;\n    gl_Position = mvp * vec4(vertexPosition, 1.0);\n}\n"
  },
  {
    "path": "meshroom/ui/qml/Viewer3D/Materials/shaders/robustwireframe.frag",
    "content": "#version 450 \n\n#extension GL_NV_fragment_shader_barycentric : require\n\nlayout(location = 0) out vec4 fragColor;\n\nvoid main()\n{\n    vec3 barycentric = gl_BaryCoordNV;\n\n    float mindist = min(min(barycentric.x, barycentric.y), barycentric.z);\n\n    if (mindist < 0.05)\n    {\n        fragColor = vec4(1.0, 1.0, 1.0, 1.0);\n    }\n}\n"
  },
  {
    "path": "meshroom/ui/qml/Viewer3D/Materials/shaders/robustwireframe.vert",
    "content": "#version 450 core\n\nlayout(location = 0) in vec3 vertexPosition;\n\nlayout(std140, binding = 0) uniform qt3d_render_view_uniforms {\n  mat4 viewMatrix;\n  mat4 projectionMatrix;\n  mat4 uncorrectedProjectionMatrix;\n  mat4 clipCorrectionMatrix;\n  mat4 viewProjectionMatrix;\n  mat4 inverseViewMatrix;\n  mat4 inverseProjectionMatrix;\n  mat4 inverseViewProjectionMatrix;\n  mat4 viewportMatrix;\n  mat4 inverseViewportMatrix;\n  vec4 textureTransformMatrix;\n  vec3 eyePosition;\n  float aspectRatio;\n  float gamma;\n  float exposure;\n  float time;\n  float yUpInNDC;\n  float yUpInFBO;\n};\n\nlayout(std140, binding = 1) uniform qt3d_command_uniforms {\n  mat4 modelMatrix;\n  mat4 inverseModelMatrix;\n  mat4 modelViewMatrix;\n  mat3 modelNormalMatrix;\n  mat4 inverseModelViewMatrix;\n  mat4 modelViewProjection;\n  mat4 inverseModelViewProjectionMatrix;\n};\n\nvoid main()\n{\n    gl_Position = projectionMatrix * viewMatrix * modelMatrix * vec4( vertexPosition, 1.0 );\n}\n"
  },
  {
    "path": "meshroom/ui/qml/Viewer3D/MediaCache.qml",
    "content": "import QtQuick\nimport Qt3D.Core 2.6\nimport Qt3D.Render 2.6\n\nimport Utils 1.0\n\nEntity {\n    id: root\n\n    /// Enable debug mode (show cache entity with a scale applied)\n    property bool debug: false\n\n    enabled: false  // Disabled entity\n\n    components: [\n        Transform {\n            id: transform\n            scale: 1\n        }\n    ]\n\n    StateGroup {\n        states: [\n            State {\n                when: root.debug\n                name: \"debug\"\n                PropertyChanges {\n                    target: root\n                    enabled: true\n                }\n                PropertyChanges {\n                    target: transform\n                    scale: 0.2\n                }\n            }\n        ]\n    }\n\n    property int cacheSize: 2\n    property var mediaCache: {[]}\n\n    /// The current number of managed entities\n    function currentSize() {\n        return Object.keys(mediaCache).length\n    }\n\n    /// Whether the cache contains an entity for the given source\n    function contains(source) {\n        return mediaCache[source] !== undefined\n    }\n\n    /// Add an entity to the cache\n    function add(source, object) {\n        if (!Filepath.exists(source))\n            return false\n        if (contains(source))\n            return true\n        if (debug) { console.log(\"[cache] add: \" + source) }\n        mediaCache[source] = object\n        object.parent = root\n        // Remove oldest entry in cache\n        if (currentSize() > cacheSize)\n            shrink()\n        return true\n    }\n\n    /// Pop an entity from the cache based on its source\n    function pop(source) {\n        if (!contains(source))\n            return undefined\n\n        var obj = mediaCache[source]\n        delete mediaCache[source]\n        if (debug) { console.log(\"[cache] pop: \" + source) }\n        // Delete cached obj if file does not exist on disk anymore\n        if (!Filepath.exists(source)) {\n            if (debug) { console.log(\"[cache] destroy: \" + source) }\n            obj.destroy()\n            obj = undefined\n        }\n        return obj\n    }\n\n    /// Remove and destroy an entity from cache\n    function destroyEntity(source) {\n        var obj = pop(source)\n        if (obj)\n            obj.destroy()\n    }\n\n    // Shrink cache to fit max size\n    function shrink() {\n        while (currentSize() > cacheSize)\n            destroyEntity(Object.keys(mediaCache)[0])\n    }\n\n    // Clear cache and destroy all managed entities\n    function clear() {\n        Object.keys(mediaCache).forEach(function(key) {\n            destroyEntity(key)\n        })\n    }\n}\n"
  },
  {
    "path": "meshroom/ui/qml/Viewer3D/MediaLibrary.qml",
    "content": "import QtQuick\nimport Qt3D.Core 2.6\nimport Qt3D.Render 2.6\n\nimport Utils 1.0\n\n\n/**\n * MediaLibrary is an Entity that loads and manages a list of 3D media.\n * It also uses an internal cache to instantly reload media.\n */\n\nEntity {\n    id: root\n\n    readonly property alias model: m.mediaModel\n    property int renderMode\n    property bool pickingEnabled: false\n    readonly property alias count: instantiator.count  // Number of instantiated media delegates\n\n    // For TransformGizmo in BoundingBox\n    property DefaultCameraController sceneCameraController\n    property Layer frontLayerComponent\n    property var window\n\n    /// Camera to consider for positioning\n    property Camera camera: null\n\n    /// True while at least one media is being loaded\n    readonly property bool loading: {\n        for (var i = 0; i < m.mediaModel.count; ++i) {\n            if (m.mediaModel.get(i).status === SceneLoader.Loading)\n                return true\n        }\n        return false\n    }\n\n    signal clicked(var pick)\n    signal loadRequest(var idx)\n\n    QtObject {\n        id: m\n        property ListModel mediaModel: ListModel { dynamicRoles: true }\n        property var sourceToEntity: ({})\n\n        readonly property var mediaElement: ({\n            \"source\": \"\",\n            \"valid\": true,\n            \"label\": \"\",\n            \"visible\": true,\n            \"hasBoundingBox\": false,  // For Meshing node only\n            \"displayBoundingBox\": true,  // For Meshing node only\n            \"hasTransform\": false,  // For SfMTransform node only\n            \"displayTransform\": true,  // For SfMTransform node only\n            \"section\": \"\",\n            \"attribute\": null,\n            \"entity\": null,\n            \"requested\": true,\n            \"vertexCount\": 0,\n            \"faceCount\": 0,\n            \"cameraCount\": 0,\n            \"textureCount\": 0,\n            \"resectionIdCount\": 0,\n            \"resectionId\": 0,\n            \"resectionGroups\": [],\n            \"status\": SceneLoader.None\n        })\n    }\n\n    function makeElement(values) {\n        return Object.assign({}, JSON.parse(JSON.stringify(m.mediaElement)), values)\n    }\n\n    function ensureVisible(source) {\n        var idx = find(source)\n        if (idx === -1)\n            return\n        m.mediaModel.get(idx).visible = true\n        loadRequest(idx)\n    }\n\n    function find(source) {\n        for (var i = 0; i < m.mediaModel.count; ++i) {\n            var elt = m.mediaModel.get(i)\n            if (elt.source === source || elt.attribute === source)\n                return i\n        }\n        return -1\n    }\n\n    function load(filepath, label = undefined) {\n        var pathStr = Filepath.urlToString(filepath)\n        if (!Filepath.exists(pathStr)) {\n            console.warn(\"Media Error: File \" + pathStr + \" does not exist.\")\n            return\n        }\n        // File already loaded, return\n        if (m.sourceToEntity[pathStr]) {\n            ensureVisible(pathStr)\n            return\n        }\n\n        // Add file to the internal ListModel\n        m.mediaModel.append(\n            makeElement({\n                \"source\": pathStr,\n                \"label\": label ? label : Filepath.basename(pathStr),\n                \"section\": \"External\"\n        }))\n\n    }\n\n    function view(attribute) {\n        if (m.sourceToEntity[attribute]) {\n            ensureVisible(attribute)\n            return\n        }\n\n        var section = attribute.node.label\n\n        // Add file to the internal ListModel\n        m.mediaModel.append(\n            makeElement({\n                \"label\": `${section}.${attribute.label}`,\n                \"section\": section,\n                \"attribute\": attribute\n        }))\n         \n    }\n\n    function remove(index) {\n        // Remove corresponding entry from model\n        m.mediaModel.remove(index)\n    }\n\n    /// Get entity based on source\n    function entity(source) {\n        return sourceToEntity[source]\n    }\n\n    function entityAt(index) {\n        return instantiator.objectAt(index)\n    }\n\n    function solo(index) {\n        for (var i = 0; i < m.mediaModel.count; ++i) {\n            m.mediaModel.setProperty(i, \"visible\", i === index)\n        }\n    }\n\n    function clear() {\n        m.mediaModel.clear()\n        cache.clear()\n    }\n\n    // Cache that keeps in memory the last unloaded 3D media\n    MediaCache {\n        id: cache\n    }\n\n    NodeInstantiator {\n        id: instantiator\n        model: m.mediaModel\n\n        delegate: Entity {\n            id: instantiatedEntity\n            property alias fullyInstantiated: mediaLoader.fullyInstantiated\n            readonly property alias modelSource: mediaLoader.modelSource\n\n            // Get the node\n            property var currentNode: model.attribute ? model.attribute.node : null\n            property string nodeType: currentNode ? currentNode.nodeType: null\n\n            // Specific properties to the MESHING node (declared and initialized for every Entity anyway)\n            property bool hasBoundingBox: {\n                if (currentNode && currentNode.hasAttribute(\"useBoundingBox\")) // Can have a BoundingBox\n                    return currentNode.attribute(\"useBoundingBox\").value\n                return false\n            }\n            onHasBoundingBoxChanged: model.hasBoundingBox = hasBoundingBox\n            property bool displayBoundingBox: model.displayBoundingBox\n\n            // Specific properties to the SFMTRANSFORM node (declared and initialized for every Entity anyway)\n            property bool hasTransform: {\n                if (nodeType === \"SfMTransform\" && currentNode.attribute(\"method\")) // Can have a Transform\n                    return currentNode.attribute(\"method\").value === \"manual\"\n                return false\n            }\n            onHasTransformChanged: model.hasTransform = hasTransform\n            property bool displayTransform: model.displayTransform\n\n\n            // Create the medias\n            MediaLoader {\n                id: mediaLoader\n\n                cameraPickingEnabled: !sceneCameraController.pickingActive\n\n                // Whether MediaLoader has been fully instantiated by the NodeInstantiator\n                property bool fullyInstantiated: false\n\n                // Explicitly store some attached model properties for outside use and ease binding\n                readonly property var attribute: model.attribute\n                readonly property int idx: index\n                readonly property var modelSource: attribute || model.source\n                readonly property bool visible: model.visible\n\n                // Multi-step binding to ensure MediaLoader source is properly\n                // updated when needed, whether raw source is valid or not\n\n                // Raw source path\n                property string rawSource: attribute ? attribute.value : model.source\n                // Whether dependencies are statified (applies for output/connected input attributes only)\n                readonly property bool dependencyReady: {\n                    if (attribute) {\n                        const rootAttribute = attribute.isLink ? attribute.inputRootLink : attribute\n                        if (rootAttribute.isOutput)\n                            return rootAttribute.node.globalStatus === \"SUCCESS\"\n                    }\n                    return true  // Is an input param without link (so no dependency) or an external file\n                }\n                // Source based on raw source + dependency status\n                property string currentSource: dependencyReady ? rawSource : \"\"\n                // Source based on currentSource + \"requested\" property\n                property string finalSource: model.requested ? currentSource : \"\"\n\n                // To use only if we want to draw the input source and not the current node output (Warning: to use with caution)\n                // There is maybe a better way to do this to avoid overwriting bindings which should be readonly properties\n                function drawInputSource() {\n                    rawSource = Qt.binding(() => instantiatedEntity.currentNode ? instantiatedEntity.currentNode.attribute(\"input\").value: \"\")\n                    currentSource = Qt.binding(() => rawSource)\n                    finalSource = Qt.binding(() => rawSource)\n                }\n\n                camera: root.camera\n                renderMode: root.renderMode\n                enabled: visible\n\n                property bool alive: attribute ? attribute.node.alive : false\n                onAliveChanged: {\n                    if (!alive && index >= 0)\n                          remove(index)\n                }\n\n                // 'visible' property drives media loading request\n                onVisibleChanged: {\n                    // Always request media loading if visible\n                    if (model.visible)\n                        model.requested = true\n                    // Only cancel loading request if media is not valid\n                    // (a media will not be unloaded if already loaded, only hidden)\n                    else if (!model.valid)\n                        model.requested = false\n                }\n\n                function updateCacheAndModel(forceRequest) {\n                    // Don't cache explicitly unloaded media\n                    if (model.requested && object && dependencyReady) {\n                        // Cache current object\n                        if (cache.add(Filepath.urlToString(mediaLoader.source), object))\n                            object = null\n                    }\n                    updateModel(forceRequest)\n                }\n\n                function updateModel(forceRequest) {\n                    // Update model's source path if input is an attribute\n                    if (attribute) {\n                        model.source = rawSource\n                    }\n                    // Auto-restore entity if raw source is in cache\n                    model.requested = forceRequest || (!model.valid && model.requested) || cache.contains(rawSource)\n                    model.valid = Filepath.exists(rawSource) && dependencyReady\n                }\n\n                Component.onCompleted: {\n                    // Keep 'source' -> 'entity' reference\n                    m.sourceToEntity[modelSource] = instantiatedEntity\n                    // Always request media loading when delegate has been created\n                    updateModel(true)\n                    // If external media failed to open, remove element from model\n                    if (!attribute && !object)\n                        remove(index)\n                }\n\n                onCurrentSourceChanged: {\n                    updateCacheAndModel(false)\n\n                    // Avoid the bounding box to disappear when we move it after a mesh already computed\n                    if (instantiatedEntity.hasBoundingBox && !currentSource)\n                        model.visible = true\n                }\n\n                onFinalSourceChanged: {\n                    // Update media visibility\n                    // (useful if media was explicitly unloaded or hidden but loaded back from cache)\n                    model.visible = model.requested\n\n                    var cachedObject = cache.pop(rawSource)\n                    cached = cachedObject !== undefined\n                    if (cached) {\n                        object = cachedObject\n                        // Only change cached object parent if mediaLoader has been fully instantiated\n                        // by the NodeInstantiator; otherwise re-parenting will fail silently and the object will disappear...\n                        // see \"onFullyInstantiatedChanged\" and parent NodeInstantiator's \"onObjectAdded\"\n                        if (fullyInstantiated) {\n                            object.parent = mediaLoader\n                        }\n                    }\n                    mediaLoader.source = Filepath.stringToUrl(finalSource)\n                    if (object) {\n                        // Bind media info to corresponding model roles\n                        // (test for object validity to avoid error messages right after object has been deleted)\n                        var boundProperties = [\"vertexCount\", \"faceCount\", \"cameraCount\", \"textureCount\", \"resectionIdCount\", \"resectionId\", \"resectionGroups\"]\n                        boundProperties.forEach(function(prop) {\n                            model[prop] = Qt.binding(function() { return object ? object[prop] : 0 })\n                        })\n                    } else if (finalSource && status === Component.Ready) {\n                        // Source was valid but no loader was created, remove element\n                        // Check if component is ready to avoid removing element from the model before adding instance to the node\n                        remove(index)\n                    }\n                }\n\n                onFullyInstantiatedChanged: {\n                    // Delayed reparenting of object coming from the cache\n                    if (object)\n                        object.parent = mediaLoader\n                }\n\n                onStatusChanged: {\n                    model.status = status\n                    // Remove model entry for external media that failed to load\n                    if (status === SceneLoader.Error && !model.attribute)\n                        remove(index)\n                }\n\n                components: [\n                    ObjectPicker {\n                        enabled: mediaLoader.enabled && pickingEnabled\n                        hoverEnabled: false\n                        onClicked: function(pick) { root.clicked(pick) }\n                    }\n                ]\n            }\n\n            // Transform: display a TransformGizmo for SfMTransform node only\n            // Note: use a NodeInstantiator to evaluate if the current node is a SfMTransform node and if the transform mode is set to Manual\n            NodeInstantiator {\n                id: sfmTransformGizmoInstantiator\n                active: instantiatedEntity.hasTransform\n                model: 1\n\n                SfMTransformGizmo {\n                    id: sfmTransformGizmoEntity\n                    sceneCameraController: root.sceneCameraController\n                    frontLayerComponent: root.frontLayerComponent\n                    window: root.window\n                    currentSfMTransformNode: instantiatedEntity.currentNode\n                    enabled: mediaLoader.visible && instantiatedEntity.displayTransform\n\n                    Component.onCompleted: {\n                        mediaLoader.drawInputSource()  // Because we are sure we want to show the input in MANUAL mode only\n                        Scene3DHelper.addComponent(mediaLoader, sfmTransformGizmoEntity.objectTransform)  // Add the transform to the media to see real-time transformations\n                    }\n                }\n            }\n\n            // BoundingBox: display bounding box for MESHING computation\n            // Note: use a NodeInstantiator to evaluate if the current node is a MESHING node and if the checkbox is active\n            NodeInstantiator {\n                id: boundingBoxInstantiator\n                active: instantiatedEntity.hasBoundingBox\n                model: 1\n\n                MeshingBoundingBox {\n                    sceneCameraController: root.sceneCameraController\n                    frontLayerComponent: root.frontLayerComponent\n                    window: root.window\n                    currentMeshingNode: instantiatedEntity.currentNode\n                    enabled: mediaLoader.visible && instantiatedEntity.displayBoundingBox\n                }\n            }\n        }\n\n        onObjectAdded: function(index, object) {\n            // Notify object that it is now fully instantiated\n            object.fullyInstantiated = true\n\n            // We only update the actually displayed attributes if the media.source is an attribute. \n            // A string mean that a file has been dropped on the viewer3D\n            if (object.modelSource &&object.modelSource.hasOwnProperty(\"desc\")) {\n                _currentScene.displayedAttrs3D.append(object.modelSource)\n            }\n\n        }\n\n        onObjectRemoved: function(index, object) {\n            if (m.sourceToEntity[object.modelSource])\n                \n                delete m.sourceToEntity[object.modelSource]\n\n                // We only update the actually displayed attributes if the media.source is an attribute. \n                // A string mean that a file has been dropped on the viewer3D\n                if(object.modelSource && object.modelSource.hasOwnProperty(\"desc\")) {\n                    _currentScene.displayedAttrs3D.remove(object.modelSource)\n                }\n                                      \n        }\n\n    }\n}\n"
  },
  {
    "path": "meshroom/ui/qml/Viewer3D/MediaLoader.qml",
    "content": "import QtQuick\nimport Qt3D.Core 2.6\nimport Qt3D.Render 2.6\nimport Qt3D.Extras 2.15\nimport QtQuick.Scene3D 2.6\n\nimport \"Materials\"\nimport Utils 1.0\n\n/**\n * MediaLoader provides a single entry point for 3D media loading.\n * It encapsulates all available plugins/loaders.\n */\n\n Entity {\n    id: root\n\n    property url source\n    property bool loading: false\n    property int status: SceneLoader.None\n    property var object: null\n    property int renderMode\n\n    /// Scene's current camera\n    property Camera camera: null\n\n    property bool cached: false\n    property bool cameraPickingEnabled: false\n\n    onSourceChanged: {\n        if (cached) {\n            root.status = SceneLoader.Ready\n            return\n        }\n\n        // Clear previously created object if any\n        if (object) {\n            object.destroy()\n            object = null\n        }\n\n        var component = undefined\n        status = SceneLoader.Loading\n\n        if (!Filepath.exists(source)) {\n            status = SceneLoader.None\n            return\n        }\n\n        switch (Filepath.extension(source)) {\n            case \".ply\":\n                if ((Filepath.extension(Filepath.removeExtension(source))) == \".pc\") {\n                    if (Viewer3DSettings.supportSfmData)\n                        component = sfmDataLoaderEntityComponent\n                }\n                else {\n                    component = sceneLoaderEntityComponent\n                }\n                break\n            case \".abc\": \n            case \".json\":\n            case \".sfm\":\n                if (Viewer3DSettings.supportSfmData)\n                    component = sfmDataLoaderEntityComponent\n                break\n            case \".exr\":\n                if (Viewer3DSettings.supportDepthMap)\n                    component = exrLoaderComponent\n                break\n            case \".obj\":\n            case \".stl\":\n            default:\n                component = sceneLoaderEntityComponent\n                break\n        }\n\n        // Media loader available\n        if (component) {\n            object = component.createObject(root, {\"source\": source})\n        }\n    }\n\n    Component {\n        id: sceneLoaderEntityComponent\n        MediaLoaderEntity {\n            id: sceneLoaderEntity\n            objectName: \"SceneLoader\"\n\n            components: [\n                SceneLoader {\n                    source: parent.source\n                    onStatusChanged: function(status) {\n                        if (status == SceneLoader.Ready) {\n                            textureCount = sceneLoaderPostProcess(sceneLoaderEntity)\n                            faceCount = Scene3DHelper.faceCount(sceneLoaderEntity)\n                        }\n                        root.status = status;\n                    }\n                }\n            ]\n        }\n    }\n\n    Component {\n        id: sfmDataLoaderEntityComponent\n        MediaLoaderEntity {\n            id: sfmDataLoaderEntity\n            Component.onCompleted: {\n                var obj = Viewer3DSettings.sfmDataLoaderComp.createObject(sfmDataLoaderEntity, {\n                                               \"source\": source,\n                                               \"fixedPointSize\": Qt.binding(function() { return Viewer3DSettings.fixedPointSize }),\n                                               \"pointSize\": Qt.binding(function() { return Viewer3DSettings.pointSize }),\n                                               \"locatorScale\": Qt.binding(function() { return Viewer3DSettings.cameraScale }),\n                                               \"cameraPickingEnabled\": Qt.binding(function() { return root.enabled && root.cameraPickingEnabled }),\n                                               \"resectionId\": Qt.binding(function() { return Viewer3DSettings.resectionId }),\n                                               \"displayResections\": Qt.binding(function() { return Viewer3DSettings.displayResectionIds }),\n                                               \"syncPickedViewId\": Qt.binding(function() { return Viewer3DSettings.syncWithPickedViewId })\n                                           });\n\n                obj.statusChanged.connect(function() {\n                    if (obj.status === SceneLoader.Ready) {\n                        for (var i = 0; i < obj.pointClouds.length; ++i) {\n                            vertexCount += Scene3DHelper.vertexCount(obj.pointClouds[i])\n                        }\n                        cameraCount = obj.spawnCameraSelectors()\n                    }\n                    Viewer3DSettings.resectionIdCount = obj.countResectionIds()\n                    Viewer3DSettings.resectionGroups = obj.countResectionGroups(Viewer3DSettings.resectionIdCount + 1)\n                    resectionIdCount = Viewer3DSettings.resectionIdCount\n                    resectionGroups = Viewer3DSettings.resectionGroups\n                    resectionId = Viewer3DSettings.resectionIdCount\n                    root.status = obj.status\n                })\n\n                obj.cameraSelected.connect(\n                    function(viewId) {\n                        if (viewId) {\n                            obj.selectedViewId = viewId\n                        }\n                    }\n                )\n            }\n        }\n    }\n\n    Component {\n        id: exrLoaderComponent\n        MediaLoaderEntity {\n            id: exrLoaderEntity\n            Component.onCompleted: {\n                var fSize = Filepath.fileSizeMB(source)\n                if (fSize > 500) {\n                    // Do not load images that are larger than 500MB\n                    console.warn(\"Viewer3D: Do not load the EXR in 3D as the file size is too large: \" + fSize + \"MB\")\n                    root.status = SceneLoader.Error\n                    return\n                }\n\n                // EXR loading strategy:\n                //   - [1] as a depth map\n                var obj = Viewer3DSettings.depthMapLoaderComp.createObject(\n                            exrLoaderEntity, {\n                                \"source\": source\n                            })\n\n                if (obj.status === SceneLoader.Ready) {\n                    faceCount = Scene3DHelper.faceCount(obj)\n                    root.status = SceneLoader.Ready\n                    return\n                }\n\n                //   - [2] as an environment map\n                obj.destroy()\n                root.status = SceneLoader.Loading\n                obj = Qt.createComponent(\"EnvironmentMapEntity.qml\").createObject(\n                            exrLoaderEntity, {\n                                \"source\": source,\n                                \"position\": Qt.binding(function() { return root.camera.position })\n                            })\n                obj.statusChanged.connect(function() {\n                    root.status = obj.status;\n                })\n            }\n        }\n    }\n\n    Component {\n        id: materialSwitcherComponent\n        MaterialSwitcher { }\n    }\n\n    // Remove automatically created DiffuseMapMaterial and\n    // instantiate a MaterialSwitcher instead. Returns the faceCount\n    function sceneLoaderPostProcess(rootEntity)\n    {\n        var materials = Scene3DHelper.findChildrenByProperty(rootEntity, \"diffuse\")\n        var entities = []\n        var texCount = 0\n        materials.forEach(function(mat) {\n            entities.push(mat.parent)\n        })\n\n        entities.forEach(function(entity) {\n            var mats = []\n            var componentsToRemove = []\n            // Create as many MaterialSwitcher as individual materials for this entity\n            // NOTE: we let each MaterialSwitcher modify the components of the entity\n            //       and therefore remove the default material spawned by the sceneLoader\n            for (var i = 0; i < entity.components.length; ++i)\n            {\n                var comp = entity.components[i]\n\n                // Handle DiffuseMapMaterials created by SceneLoader\n                if (comp.toString().indexOf(\"QDiffuseMapMaterial\") > -1) {\n                    // Store material definition\n                    var m = {\n                        \"diffuseMap\": comp.diffuse.data[0].source,\n                        \"shininess\": comp.shininess,\n                        \"specular\": comp.specular,\n                        \"ambient\": comp.ambient,\n                        \"mode\": root.renderMode\n                    }\n                    texCount++\n                    mats.push(m)\n                    componentsToRemove.push(comp)\n                }\n\n                if (comp.toString().indexOf(\"QPhongMaterial\") > -1) {\n                    // Create MaterialSwitcher with default colors\n                    mats.push({})\n                    componentsToRemove.push(comp)\n                }\n            }\n\n            mats.forEach(function(m) {\n                // Create a material switcher for each material definition\n                var matSwitcher = materialSwitcherComponent.createObject(entity, m)\n                matSwitcher.mode = Qt.binding(function() { return root.renderMode })\n            })\n\n            // Remove replaced components\n            componentsToRemove.forEach(function(comp) {\n                Scene3DHelper.removeComponent(entity, comp)\n            })\n        })\n        return texCount\n    }\n}\n"
  },
  {
    "path": "meshroom/ui/qml/Viewer3D/MediaLoaderEntity.qml",
    "content": "import QtQuick\nimport Qt3D.Core 2.6\n\n/**\n * MediaLoaderEntity provides a unified interface for accessing statistics\n * of a 3D media independently from the way it was loaded.\n */\n\nEntity {\n    property url source\n\n    /// Number of vertices\n    property int vertexCount\n    /// Number of faces\n    property int faceCount\n    /// Number of cameras\n    property int cameraCount\n    /// Number of textures\n    property int textureCount\n    /// Number of resection IDs\n    property int resectionIdCount\n    /// Current resection ID\n    property int resectionId\n    /// Groups of cameras based on resection IDs\n    property var resectionGroups\n}\n"
  },
  {
    "path": "meshroom/ui/qml/Viewer3D/MeshingBoundingBox.qml",
    "content": "import Qt3D.Core 2.6\nimport Qt3D.Render 2.6\nimport Qt3D.Input 2.6\nimport Qt3D.Extras 2.15\nimport QtQuick\n\n/**\n * BoundingBox entity for Meshing node. Used to define the area to reconstruct.\n * Simple box controlled by a gizmo to give easy and visual representation.\n */\nEntity {\n    id: root\n    property DefaultCameraController sceneCameraController\n    property Layer frontLayerComponent\n    property var window\n    property var currentMeshingNode: null\n    enabled: true\n\n    EntityWithGizmo {\n        id: boundingBoxEntity\n        sceneCameraController: root.sceneCameraController\n        frontLayerComponent: root.frontLayerComponent\n        window: root.window\n\n        // Update node meshing slider values when the gizmo has changed: translation, rotation, scale, type\n        transformGizmo.onGizmoChanged: function(translation, rotation, scale, type) {\n            \n            var rotationEuler_cv = Qt.vector3d(rotation.x, rotation.y, rotation.z)\n            var rotation_gl = Transformations3DHelper.convertRotationFromCV2GL(rotationEuler_cv)\n\n            switch (type) {\n                case TransformGizmo.Type.TRANSLATION: {\n                    _currentScene.setAttribute(\n                        root.currentMeshingNode.attribute(\"boundingBox.bboxTranslation\"),\n                        JSON.stringify([translation.x, -translation.y, -translation.z])\n                    )\n                    break\n                }\n                case TransformGizmo.Type.ROTATION: {\n                    _currentScene.setAttribute(\n                        root.currentMeshingNode.attribute(\"boundingBox.bboxRotation\"),\n                        JSON.stringify([rotation_gl.x, rotation_gl.y, rotation_gl.z])\n                    )\n                    break\n                }\n                case TransformGizmo.Type.SCALE: {\n                    _currentScene.setAttribute(\n                        root.currentMeshingNode.attribute(\"boundingBox.bboxScale\"),\n                        JSON.stringify([scale.x, scale.y, scale.z])\n                    )\n                    break\n                }\n                case TransformGizmo.Type.ALL: {\n                    _currentScene.setAttribute(\n                        root.currentMeshingNode.attribute(\"boundingBox\"),\n                        JSON.stringify([\n                            [translation.x, -translation.y, -translation.z],\n                            [rotation_gl.x, rotation_gl.y, rotation_gl.z],\n                            [scale.x, scale.y, scale.z]\n                        ])\n                    )\n                    break\n                }\n            }\n        }\n\n        // Translation values from node (vector3d because this is the type of QTransform.translation)\n        property var nodeTranslation : Qt.vector3d(\n            root.currentMeshingNode ? root.currentMeshingNode.attribute(\"boundingBox.bboxTranslation.x\").value : 0,\n            root.currentMeshingNode ? -root.currentMeshingNode.attribute(\"boundingBox.bboxTranslation.y\").value : 0,\n            root.currentMeshingNode ? -root.currentMeshingNode.attribute(\"boundingBox.bboxTranslation.z\").value : 0\n        )\n\n        // Rotation values from node (3 separated values because QTransform stores Euler angles like this)\n        property var nodeRotationX: {\n            var rx = root.currentMeshingNode ? root.currentMeshingNode.attribute(\"boundingBox.bboxRotation.x\").value : 0\n            var ry = root.currentMeshingNode ? root.currentMeshingNode.attribute(\"boundingBox.bboxRotation.y\").value : 0\n            var rz = root.currentMeshingNode ? root.currentMeshingNode.attribute(\"boundingBox.bboxRotation.z\").value : 0\n\n            var rotationEuler_cv = Qt.vector3d(rx, ry, rz)\n            var rotation_gl = Transformations3DHelper.convertRotationFromCV2GL(rotationEuler_cv)\n            return rotation_gl.x\n        }\n\n        property var nodeRotationY: {\n            var rx = root.currentMeshingNode ? root.currentMeshingNode.attribute(\"boundingBox.bboxRotation.x\").value : 0\n            var ry = root.currentMeshingNode ? root.currentMeshingNode.attribute(\"boundingBox.bboxRotation.y\").value : 0\n            var rz = root.currentMeshingNode ? root.currentMeshingNode.attribute(\"boundingBox.bboxRotation.z\").value : 0\n\n            var rotationEuler_cv = Qt.vector3d(rx, ry, rz)\n            var rotation_gl = Transformations3DHelper.convertRotationFromCV2GL(rotationEuler_cv)\n            return rotation_gl.y\n        }\n\n        property var nodeRotationZ: {\n            var rx = root.currentMeshingNode ? root.currentMeshingNode.attribute(\"boundingBox.bboxRotation.x\").value : 0\n            var ry = root.currentMeshingNode ? root.currentMeshingNode.attribute(\"boundingBox.bboxRotation.y\").value : 0\n            var rz = root.currentMeshingNode ? root.currentMeshingNode.attribute(\"boundingBox.bboxRotation.z\").value : 0\n\n            var rotationEuler_cv = Qt.vector3d(rx, ry, rz)\n            var rotation_gl = Transformations3DHelper.convertRotationFromCV2GL(rotationEuler_cv)\n            return rotation_gl.z\n        }\n\n        // Scale values from node (vector3d because this is the type of QTransform.scale3D)\n        property var nodeScale: Qt.vector3d(\n            root.currentMeshingNode ? root.currentMeshingNode.attribute(\"boundingBox.bboxScale.x\").value : 1,\n            root.currentMeshingNode ? root.currentMeshingNode.attribute(\"boundingBox.bboxScale.y\").value : 1,\n            root.currentMeshingNode ? root.currentMeshingNode.attribute(\"boundingBox.bboxScale.z\").value : 1\n        )\n\n        // Automatically evaluate the Transform: value is taken from the node OR from the actual modification if the gizmo is moved by mouse.\n        // 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.\n        transformGizmo.gizmoDisplayTransform.translation: transformGizmo.focusGizmoPriority ? transformGizmo.gizmoDisplayTransform.translation : nodeTranslation\n        transformGizmo.gizmoDisplayTransform.rotationX: transformGizmo.focusGizmoPriority ? transformGizmo.gizmoDisplayTransform.rotationX : nodeRotationX\n        transformGizmo.gizmoDisplayTransform.rotationY: transformGizmo.focusGizmoPriority ? transformGizmo.gizmoDisplayTransform.rotationY : nodeRotationY\n        transformGizmo.gizmoDisplayTransform.rotationZ: transformGizmo.focusGizmoPriority ? transformGizmo.gizmoDisplayTransform.rotationZ : nodeRotationZ\n        transformGizmo.objectTransform.scale3D: transformGizmo.focusGizmoPriority ? transformGizmo.objectTransform.scale3D : nodeScale\n\n        // The entity\n        BoundingBox { transform: boundingBoxEntity.objectTransform }\n    }\n}\n"
  },
  {
    "path": "meshroom/ui/qml/Viewer3D/SfMTransformGizmo.qml",
    "content": "import Qt3D.Core 2.6\nimport Qt3D.Render 2.6\nimport Qt3D.Input 2.6\nimport Qt3D.Extras 2.15\n\nimport QtQuick\n\n/**\n * Gizmo for SfMTransform node.\n * Uses EntityWithGizmo wrapper because we should not instantiate TransformGizmo alone.\n */\n\nEntity {\n    id: root\n    property DefaultCameraController sceneCameraController\n    property Layer frontLayerComponent\n    property var window\n    property var currentSfMTransformNode: null\n    enabled: true\n    \n    readonly property alias objectTransform: sfmTranformGizmoEntity.objectTransform // The Transform the object should use\n\n    EntityWithGizmo {\n        id: sfmTranformGizmoEntity\n        sceneCameraController: root.sceneCameraController\n        frontLayerComponent: root.frontLayerComponent\n        window: root.window\n        uniformScale: true  // We want to make uniform scale transformations\n\n        // Update node SfMTransform slider values when the gizmo has changed: translation, rotation, scale, type\n        transformGizmo.onGizmoChanged: {\n            switch (type) {\n                case TransformGizmo.Type.TRANSLATION: {\n                    _currentScene.setAttribute(\n                        root.currentSfMTransformNode.attribute(\"manualTransform.manualTranslation\"),\n                        JSON.stringify([translation.x, translation.y, translation.z])\n                    )\n                    break\n                }\n                case TransformGizmo.Type.ROTATION: {\n                    _currentScene.setAttribute(\n                        root.currentSfMTransformNode.attribute(\"manualTransform.manualRotation\"),\n                        JSON.stringify([rotation.x, rotation.y, rotation.z])\n                    )\n                    break\n                }\n                case TransformGizmo.Type.SCALE: {\n                    // Only one scale is needed since the scale is uniform\n                    _currentScene.setAttribute(\n                        root.currentSfMTransformNode.attribute(\"manualTransform.manualScale\"),\n                        scale.x\n                    )\n                    break\n                }\n                case TransformGizmo.Type.ALL: {\n                    _currentScene.setAttribute(\n                        root.currentSfMTransformNode.attribute(\"manualTransform\"),\n                        JSON.stringify([\n                            [translation.x, translation.y, translation.z],\n                            [rotation.x, rotation.y, rotation.z],\n                            scale.x\n                        ])\n                    )\n                    break\n                }\n            }\n        }\n\n        // Translation values from node (vector3d because this is the type of QTransform.translation)\n        property var nodeTranslation : Qt.vector3d(\n            root.currentSfMTransformNode ? root.currentSfMTransformNode.attribute(\"manualTransform.manualTranslation.x\").value : 0,\n            root.currentSfMTransformNode ? root.currentSfMTransformNode.attribute(\"manualTransform.manualTranslation.y\").value : 0,\n            root.currentSfMTransformNode ? root.currentSfMTransformNode.attribute(\"manualTransform.manualTranslation.z\").value : 0\n        )\n        // Rotation values from node (3 separated values because QTransform stores Euler angles like this)\n        property var nodeRotationX: root.currentSfMTransformNode ? root.currentSfMTransformNode.attribute(\"manualTransform.manualRotation.x\").value : 0\n        property var nodeRotationY: root.currentSfMTransformNode ? root.currentSfMTransformNode.attribute(\"manualTransform.manualRotation.y\").value : 0\n        property var nodeRotationZ: root.currentSfMTransformNode ? root.currentSfMTransformNode.attribute(\"manualTransform.manualRotation.z\").value : 0\n        // Scale value from node (simple number because we use uniform scale)\n        property var nodeScale: root.currentSfMTransformNode ? root.currentSfMTransformNode.attribute(\"manualTransform.manualScale\").value : 1\n\n        // Automatically evaluate the Transform: value is taken from the node OR from the actual modification if the gizmo is moved by mouse.\n        // 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.\n        transformGizmo.gizmoDisplayTransform.translation: transformGizmo.focusGizmoPriority ? transformGizmo.gizmoDisplayTransform.translation : nodeTranslation\n        transformGizmo.gizmoDisplayTransform.rotationX: transformGizmo.focusGizmoPriority ? transformGizmo.gizmoDisplayTransform.rotationX : nodeRotationX\n        transformGizmo.gizmoDisplayTransform.rotationY: transformGizmo.focusGizmoPriority ? transformGizmo.gizmoDisplayTransform.rotationY : nodeRotationY\n        transformGizmo.gizmoDisplayTransform.rotationZ: transformGizmo.focusGizmoPriority ? transformGizmo.gizmoDisplayTransform.rotationZ : nodeRotationZ\n        transformGizmo.objectTransform.scale3D: transformGizmo.focusGizmoPriority ? transformGizmo.objectTransform.scale3D : Qt.vector3d(nodeScale, nodeScale, nodeScale)\n    }\n}\n"
  },
  {
    "path": "meshroom/ui/qml/Viewer3D/SfmDataLoader.qml",
    "content": "import QtQuick\nimport Qt3D.Core 2.6\nimport Qt3D.Render 2.6\nimport Qt3D.Extras 2.15\n\nimport SfmDataEntity 1.0\n\n/**\n * Support for SfMData files in Qt3D.\n * Create this component dynamically to test for SfmDataEntity plugin availability.\n */\n\nSfmDataEntity {\n    id: root\n\n    property bool cameraPickingEnabled: true\n    property bool syncPickedViewId: false\n\n    // Filter out non-reconstructed cameras\n    skipHidden: true\n\n    signal cameraSelected(var viewId)\n\n    Connections {\n        target: _currentScene\n        function onSelectedViewIdChanged() {\n            root.cameraSelected(_currentScene.selectedViewId)\n        }\n        function onSelectedViewpointChanged() {\n            root.cameraSelected(_currentScene.pickedViewId)\n        }\n    }\n\n    function spawnCameraSelectors() {\n        var validCameras = 0;\n        // Spawn camera selector for each camera\n        for (var i = 0; i < root.cameras.length; ++i)\n        {\n            var cam = root.cameras[i];\n            // retrieve view id\n            var viewId = cam.viewId;\n            if (viewId === undefined)\n                continue;\n            camSelectionComponent.createObject(cam, {\"viewId\": viewId});\n            dummyCamSelectionComponent.createObject(cam, {\"viewId\": viewId});\n            validCameras++;\n        }\n        return validCameras;\n    }\n\n    function countResectionIds() {\n        var maxResectionId = 0\n        for (var i = 0; i < root.cameras.length; i++) {\n            var cam = root.cameras[i]\n            var resectionId = cam.resectionId\n            // 4294967295 = UINT_MAX, which might occur if the value is undefined on the C++ side\n            if (resectionId === undefined || resectionId === 4294967295)\n                continue\n            if (resectionId > maxResectionId)\n                maxResectionId = resectionId\n        }\n\n        return maxResectionId\n    }\n\n\n    function countResectionGroups(resectionIdCount) {\n        var arr = Array(resectionIdCount).fill(0)\n        for (var i = 0; i < root.cameras.length; i++) {\n            var cam = root.cameras[i]\n            var resectionId = cam.resectionId\n            // 4294967295 = UINT_MAX, which might occur if the value is undefined on the C++ side\n            if (resectionId === undefined || resectionId === 4294967295)\n                continue\n            arr[resectionId] = arr[resectionId] + 1\n        }\n\n        return arr\n    }\n\n    SystemPalette {\n        id: activePalette\n    }\n\n    // Camera selection display only\n    Component {\n        id: dummyCamSelectionComponent\n        Entity {\n            id: dummyCamSelector\n            property string viewId\n            property color customColor: Qt.hsva((parseInt(viewId) / 255.0) % 1.0, 0.3, 1.0, 1.0)\n            property real extent: cameraPickingEnabled ? 0.2 : 0\n\n            components: [\n                // Use cuboid to represent the camera\n                Transform {\n                    translation: Qt.vector3d(0, 0, 0.5 * cameraBack.zExtent)\n                },\n                CuboidMesh {\n                    id: cameraBack\n                    xExtent: parent.extent\n                    yExtent: xExtent\n                    zExtent: xExtent * 0.2\n                },\n                PhongMaterial{\n                    id: mat\n                    ambient: _currentScene && (viewId === _currentScene.selectedViewId ||\n                                                 (viewId === _currentScene.pickedViewId && syncPickedViewId)) ?\n                                 activePalette.highlight : customColor  // \"#CCC\"\n                }\n            ]\n        }\n    }\n\n    // Camera selection picking only\n    Component {\n        id: camSelectionComponent\n        Entity {\n            id: camSelector\n            property string viewId\n            property color customColor: Qt.hsva((parseInt(viewId) / 255.0) % 1.0, 0.3, 1.0, 1.0)\n            property real extent: cameraPickingEnabled ? 0.5 : 0\n\n            components: [\n                // Use cuboid to represent the camera\n                Transform {\n                    translation: Qt.vector3d(0, 0, 0.5 * cameraBack.zExtent)\n                },\n                CuboidMesh {\n                    id: cameraBack\n                    xExtent: parent.extent\n                    yExtent: xExtent\n                    zExtent: xExtent\n                },\n                ObjectPicker {\n                    id: cameraPicker\n                    property point pos\n                    onPressed: function(pick) {\n                        pos = pick.position;\n                        pick.accepted = (pick.buttons & Qt.LeftButton) && cameraPickingEnabled\n                    }\n                    onReleased: function(pick) {\n                        const delta = Qt.point(Math.abs(pos.x - pick.position.x), Math.abs(pos.y - pick.position.y))\n                        // Only trigger picking when mouse has not moved between press and release\n                        if (delta.x + delta.y < 4) {\n                            _currentScene.selectedViewId = camSelector.viewId\n                        }\n                    }\n                }\n            ]\n        }\n    }\n}\n"
  },
  {
    "path": "meshroom/ui/qml/Viewer3D/TrackballGizmo.qml",
    "content": "import Qt3D.Core 2.6\nimport Qt3D.Render 2.6\nimport Qt3D.Input 2.6\nimport Qt3D.Extras 2.15\nimport QtQuick\n\nEntity {\n    id: root\n    property real beamRadius: 0.0075\n    property real beamLength: 1\n    property int slices: 10\n    property int rings: 50\n    property color centerColor: \"white\"\n    property color xColor: \"red\"\n    property color yColor: \"green\"\n    property color zColor: \"blue\"\n    property real alpha: 1.0\n    property Transform transform: Transform {}\n\n    components: [transform]\n\n    Behavior on alpha {\n        PropertyAnimation { duration: 100 }\n    }\n\n    // Gizmo center\n    Entity {\n        components: [\n            SphereMesh { radius: beamRadius * 4},\n            PhongMaterial {\n                ambient: \"#FFF\"\n                shininess: 0.2\n                diffuse: centerColor\n                specular: centerColor\n            }\n        ]\n    }\n\n    // X, Y, Z rings\n    NodeInstantiator {\n        model: 3\n        Entity {\n            components: [\n                TorusMesh {\n                    radius: root.beamLength\n                    minorRadius: root.beamRadius\n                    slices: root.slices\n                    rings: root.rings\n                },\n                DiffuseSpecularMaterial {\n                    ambient: {\n                        switch (index) {\n                            case 0: return xColor;\n                            case 1: return yColor;\n                            case 2: return zColor;\n                        }\n                    }\n                    shininess: 0\n                    diffuse: Qt.rgba(0.6, 0.6, 0.6, root.alpha)\n                },\n\n                Transform {\n                    rotationY: index == 0 ? 90 : 0\n                    rotationX: index == 1 ? 90 : 0\n                }\n            ]\n        }\n    }\n}\n"
  },
  {
    "path": "meshroom/ui/qml/Viewer3D/TransformGizmo.qml",
    "content": "import Qt3D.Core 2.6\nimport Qt3D.Render 2.6\nimport Qt3D.Input 2.6\nimport Qt3D.Extras 2.15\nimport Qt3D.Logic 2.6\nimport QtQuick\nimport QtQuick.Controls\n\nimport Utils 1.0\n\n/**\n * Simple transformation gizmo entirely made with Qt3D entities.\n * Uses Python Transformations3DHelper to compute matrices.\n * This TransformGizmo entity should only be instantiated in EntityWithGizmo entity which is its wrapper.\n * It means, to use it for a specified application, make sure to instantiate EntityWithGizmo.\n */\n\nEntity {\n    id: root\n    property Camera camera\n    property var windowSize\n    property Layer frontLayerComponent  // Used to draw gizmo on top of everything\n    property var window\n    readonly property alias gizmoScale: gizmoScaleLookSlider.value\n    property bool uniformScale: false  // By default, the scale is not uniform\n    property bool focusGizmoPriority: false  // If true, it is used to give the priority to the current transformation (and not to a upper-level binding)\n    property Transform gizmoDisplayTransform: Transform {\n        id: gizmoDisplayTransform\n        scale: root.gizmoScale * (camera.position.minus(gizmoDisplayTransform.translation)).length()  // The gizmo needs a constant apparent size\n    }\n    // Component the object controlled by the gizmo must use\n    property Transform objectTransform : Transform {\n        translation: gizmoDisplayTransform.translation\n        rotation: gizmoDisplayTransform.rotation\n        scale3D: Qt.vector3d(1,1,1)\n    }\n    \n    signal pickedChanged(bool pressed)\n    signal gizmoChanged(var translation, var rotation, var scale, int type)\n\n    function emitGizmoChanged(type) {\n        const translation = gizmoDisplayTransform.translation  // Position in space\n        const rotation = Qt.vector3d(gizmoDisplayTransform.rotationX, gizmoDisplayTransform.rotationY, gizmoDisplayTransform.rotationZ) // Euler angles\n        const scale = objectTransform.scale3D // Scale of the object\n\n        gizmoChanged(translation, rotation, scale, type)\n        root.focusGizmoPriority = false\n    }\n\n    components: [gizmoDisplayTransform, mouseHandler, frontLayerComponent]\n\n\n    /***** ENUMS *****/\n\n    enum Axis {\n        X,\n        Y,\n        Z\n    }\n\n    enum Direction {\n        Forward,\n        Backward\n    }\n\n    enum Type {\n        TRANSLATION,\n        ROTATION,\n        SCALE,\n        ALL\n    }\n\n    function convertAxisEnum(axis) {\n        switch (axis) {\n            case TransformGizmo.Axis.X: return Qt.vector3d(1,0,0)\n            case TransformGizmo.Axis.Y: return Qt.vector3d(0,1,0)\n            case TransformGizmo.Axis.Z: return Qt.vector3d(0,0,1)\n        }\n    }\n\n    function convertDirectionEnum(direction) {\n        switch (direction) {\n            case TransformGizmo.Direction.Forward: return 1\n            case TransformGizmo.Direction.Backward: return -1\n        }\n    }\n\n    function convertTypeEnum(type) {\n        switch (type) {\n            case TransformGizmo.Type.TRANSLATION: return \"TRANSLATION\"\n            case TransformGizmo.Type.ROTATION: return \"ROTATION\"\n            case TransformGizmo.Type.SCALE: return \"SCALE\"\n            case TransformGizmo.Type.ALL: return \"ALL\"\n        }\n    }\n\n    /***** TRANSFORMATIONS (using local vars) *****/\n\n    /**\n     * @brief Translate locally the gizmo and the object.\n     *\n     * @remarks\n     *      To make local translation, we need to recompute a new matrix.\n     *      Update gizmoDisplayTransform's matrix and all its properties while avoiding the override of translation property.\n     *      Update objectTransform in the same time thanks to binding on translation property.\n     *\n     * @param initialModelMatrix object containing position, rotation and scale matrices + rotation quaternion\n     * @param translateVec vector3d used to make the local translation\n     */\n    function doRelativeTranslation(initialModelMatrix, translateVec) {\n        Transformations3DHelper.relativeLocalTranslate(\n            gizmoDisplayTransform,\n            initialModelMatrix.position,\n            initialModelMatrix.rotation,\n            initialModelMatrix.scale,\n            translateVec\n        )\n    }\n\n    /**\n     * @brief Rotate the gizmo and the object around a specific axis.\n     *\n     * @remarks\n     *      To make local rotation around an axis, we need to recompute a new matrix from a quaternion.\n     *      Update gizmoDisplayTransform's matrix and all its properties while avoiding the override of rotation, rotationX, rotationY and rotationZ properties.\n     *      Update objectTransform in the same time thanks to binding on rotation property.\n     *\n     * @param initialModelMatrix object containing position, rotation and scale matrices + rotation quaternion\n     * @param axis vector3d describing the axis to rotate around\n     * @param degree angle of rotation in degrees\n     */\n    function doRelativeRotation(initialModelMatrix, axis, degree) {\n        Transformations3DHelper.relativeLocalRotate(\n            gizmoDisplayTransform,\n            initialModelMatrix.position,\n            initialModelMatrix.quaternion,\n            initialModelMatrix.scale,\n            axis,\n            degree\n        )\n    }\n\n    /**\n     * @brief Scale the object relatively to its current scale.\n     *\n     * @remarks\n     *      To change scale of the object, we need to recompute a new matrix to avoid overriding bindings.\n     *      Update objectTransform properties only (gizmoDisplayTransform is not affected).\n     *\n     * @param initialModelMatrix object containing position, rotation and scale matrices + rotation quaternion\n     * @param scaleVec vector3d used to make the relative scale\n     */\n    function doRelativeScale(initialModelMatrix, scaleVec) {\n        Transformations3DHelper.relativeLocalScale(\n            objectTransform,\n            initialModelMatrix.position,\n            initialModelMatrix.rotation,\n            initialModelMatrix.scale,\n            scaleVec\n        )\n    }\n\n    /**\n     * @brief Reset the translation of the gizmo and the object.\n     *\n     * @remarks\n     *      Update gizmoDisplayTransform's matrix and all its properties while avoiding the override of translation property.\n     *      Update objectTransform in the same time thanks to binding on translation property.\n     */\n    function resetTranslation() {\n        const mat = gizmoDisplayTransform.matrix\n        const newMat = Qt.matrix4x4(\n            mat.m11, mat.m12, mat.m13, 0,\n            mat.m21, mat.m22, mat.m23, 0,\n            mat.m31, mat.m32, mat.m33, 0,\n            mat.m41, mat.m42, mat.m43, 1\n        )\n        gizmoDisplayTransform.setMatrix(newMat)\n    }\n\n    /**\n     * @brief Reset the rotation of the gizmo and the object.\n     *\n     * @remarks\n     *      Update gizmoDisplayTransform's quaternion while avoiding the override of rotationX, rotationY and rotationZ properties.\n     *      Update objectTransform in the same time thanks to binding on rotation property.\n     *      Here, we can change the rotation property (but not rotationX, rotationY and rotationZ because they can be used in upper-level bindings).\n     *\n     * @note\n     *      We could implement a way of changing the matrix instead of overriding rotation (quaternion) property.\n     */\n    function resetRotation() {\n        gizmoDisplayTransform.rotation = Qt.quaternion(1,0,0,0)\n    }\n\n    /**\n     * @brief Reset the scale of the object.\n     *\n     * @remarks\n     *      To reset the scale, we make the difference of the current one to 1 and recompute the matrix.\n     *      Like this, we kind of apply an inverse scale transformation.\n     *      It prevents overriding scale3D property (because it can be used in upper-level binding).\n     */\n    function resetScale() {\n        const modelMat = Transformations3DHelper.modelMatrixToMatrices(objectTransform.matrix)\n        const scaleDiff = Qt.vector3d(\n            -(objectTransform.scale3D.x - 1),\n            -(objectTransform.scale3D.y - 1),\n            -(objectTransform.scale3D.z - 1)\n        )\n        doRelativeScale(modelMat, scaleDiff)\n    }\n\n    /***** DEVICES *****/\n\n    MouseDevice { id: mouseSourceDevice }\n\n    MouseHandler {\n        id: mouseHandler\n        sourceDevice: enabled ? mouseSourceDevice : null\n        property var objectPicker: null\n        property bool enabled: false\n\n        onPositionChanged: function(mouse) {\n            if (objectPicker && objectPicker.button === Qt.LeftButton) {\n                root.focusGizmoPriority = true\n\n                // Get the selected axis\n                const pickedAxis = convertAxisEnum(objectPicker.gizmoAxis)\n\n                // TRANSLATION, SCALE or SURFACE MOVE transformation = SURFACE MOVE is a combination of TRANSLATION AND SCALE\n                if (objectPicker.gizmoType === TransformGizmo.Type.TRANSLATION || objectPicker.gizmoType === TransformGizmo.Type.SCALE || objectPicker.gizmoType === TransformGizmo.Type.ALL) {\n                    // Compute the vector PickedPosition -> CurrentMousePoint\n                    const pickedPosition = objectPicker.screenPoint\n                    const mouseVector = Qt.vector2d((mouse.x - pickedPosition.x), -(mouse.y - pickedPosition.y))\n\n\n                    // Transform the positive picked axis vector from World Coord to Screen Coord\n                    const gizmoLocalPointOnAxis = gizmoDisplayTransform.matrix.times(Qt.vector4d(pickedAxis.x, pickedAxis.y, pickedAxis.z, 1))\n                    const gizmoCenterPoint = gizmoDisplayTransform.matrix.times(Qt.vector4d(0, 0, 0, 1))\n                    const screenPoint2D = Transformations3DHelper.pointFromWorldToScreen(gizmoLocalPointOnAxis, camera, windowSize)\n                    const screenCenter2D = Transformations3DHelper.pointFromWorldToScreen(gizmoCenterPoint, camera, windowSize)\n                    const screenAxisVector = Qt.vector2d(screenPoint2D.x - screenCenter2D.x, -(screenPoint2D.y - screenCenter2D.y))\n\n                    // Get the cosinus of the angle from the screenAxisVector to the mouseVector\n                    // It will be used as a intensity factor\n                    const cosAngle = screenAxisVector.dotProduct(mouseVector) / (screenAxisVector.length() * mouseVector.length())\n                    const offset = cosAngle * mouseVector.length() / objectPicker.scaleUnit\n\n                    // Do the transformation\n                    if (objectPicker.gizmoType === TransformGizmo.Type.TRANSLATION && offset !== 0) {\n                        doRelativeTranslation(objectPicker.modelMatrix, pickedAxis.times(offset))  // Do a translation from the initial Object Model Matrix when we picked the gizmo\n                    } else if (objectPicker.gizmoType === TransformGizmo.Type.SCALE && offset !== 0) {\n                        if (root.uniformScale)\n                            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\n                        else\n                            doRelativeScale(objectPicker.modelMatrix, pickedAxis.times(offset))  // Do a scale on one axis from the initial Object Model Matrix when we picked the gizmo\n                    }\n\n                    else if (objectPicker.gizmoType === TransformGizmo.Type.ALL && offset !== 0) {\n                        const sign = convertDirectionEnum(objectPicker.gizmoDirection)\n                        doRelativeScale(objectPicker.modelMatrix, pickedAxis.times(sign * offset/2))\n                        doRelativeTranslation(objectPicker.modelMatrix, pickedAxis.times(offset/2))\n                    }\n                    return\n                }\n                // ROTATION transformation\n                else if (objectPicker.gizmoType === TransformGizmo.Type.ROTATION) {\n                    // Get Screen Coordinates of the gizmo center\n                    const gizmoCenterPoint = gizmoDisplayTransform.matrix.times(Qt.vector4d(0, 0, 0, 1))\n                    const screenCenter2D = Transformations3DHelper.pointFromWorldToScreen(gizmoCenterPoint, camera, root.windowSize)\n\n                    // Get the vector screenCenter2D -> PickedPosition\n                    const originalVector = Qt.vector2d(objectPicker.screenPoint.x - screenCenter2D.x, -(objectPicker.screenPoint.y - screenCenter2D.y))\n\n                    // Compute the vector screenCenter2D -> CurrentMousePoint\n                    const mouseVector = Qt.vector2d(mouse.x - screenCenter2D.x, -(mouse.y - screenCenter2D.y))\n\n                    // Get the angle from the originalVector to the mouseVector\n                    const angle = Math.atan2(-originalVector.y * mouseVector.x + originalVector.x * mouseVector.y, originalVector.x * mouseVector.x + originalVector.y * mouseVector.y) * 180 / Math.PI\n\n                    // Get the orientation of the gizmo in function of the camera\n                    const gizmoLocalAxisVector = gizmoDisplayTransform.matrix.times(Qt.vector4d(pickedAxis.x, pickedAxis.y, pickedAxis.z, 0))\n                    const gizmoToCameraVector = camera.position.toVector4d().minus(gizmoCenterPoint)\n                    const orientation = gizmoLocalAxisVector.dotProduct(gizmoToCameraVector) > 0 ? 1 : -1\n\n                    if (angle !== 0)\n                        doRelativeRotation(objectPicker.modelMatrix, pickedAxis, angle * orientation)  // Do a rotation from the initial Object Model Matrix when we picked the gizmo\n\n                    return\n                }\n            }\n\n            if (objectPicker && objectPicker.button === Qt.RightButton) {\n                resetMenu.popup(window)\n            }\n        }\n        onReleased: function(mouse) {\n            if (objectPicker && mouse.button === Qt.LeftButton) {\n                const type = objectPicker.gizmoType\n                objectPicker = null  // To prevent going again in the onPositionChanged\n                emitGizmoChanged(type)\n            }\n        }\n    }\n\n    Menu {\n        id: resetMenu\n\n        MenuItem {\n            text: \"Reset Translation\"\n            onTriggered: {\n                resetTranslation()\n                emitGizmoChanged(TransformGizmo.Type.TRANSLATION)\n            }\n        }\n        MenuItem {\n            text: \"Reset Rotation\"\n            onTriggered: {\n                resetRotation()\n                emitGizmoChanged(TransformGizmo.Type.ROTATION)\n            }\n        }\n        MenuItem {\n            text: \"Reset Scale\"\n            onTriggered: {\n                resetScale()\n                emitGizmoChanged(TransformGizmo.Type.SCALE)\n            }\n        }\n        MenuItem {\n            text: \"Reset All\"\n            onTriggered: {\n                resetTranslation()\n                resetRotation()\n                resetScale()\n                emitGizmoChanged(TransformGizmo.Type.ALL)\n            }\n        }\n        MenuItem {\n            text: \"Gizmo Scale Look\"\n            Slider {\n                id: gizmoScaleLookSlider\n                anchors.right: parent.right\n                anchors.rightMargin: 10\n                height: parent.height\n                width: parent.width * 0.40\n\n                from: 0.06\n                to: 0.30\n                stepSize: 0.01\n                value: 0.15\n            }\n        }\n    }\n\n    /***** GIZMO'S BASIC COMPONENTS *****/\n\n    Entity {\n        id: centerSphereEntity\n        components: [centerSphereMesh, centerSphereMaterial, frontLayerComponent]\n\n        SphereMesh {\n            id: centerSphereMesh\n            radius: 0.04\n            rings: 8\n            slices: 8\n        }\n        PhongMaterial {\n            id: centerSphereMaterial\n            property color base: \"white\"\n            ambient: base\n            shininess: 0.2\n        }\n    }\n\n    // AXIS GIZMO INSTANTIATOR => X, Y and Z\n    NodeInstantiator {\n        model: 3\n\n        Entity {\n            id: axisContainer\n            property int axis : {\n                switch (index) {\n                    case 0: return TransformGizmo.Axis.X\n                    case 1: return TransformGizmo.Axis.Y\n                    case 2: return TransformGizmo.Axis.Z\n                }                \n            }\n            property color baseColor: {\n                switch (axis) {\n                    case TransformGizmo.Axis.X: return \"#e63b55\"  // Red\n                    case TransformGizmo.Axis.Y: return \"#83c414\"  // Green\n                    case TransformGizmo.Axis.Z: return \"#3387e2\"  // Blue\n                }\n            }\n            property real lineRadius: 0.011\n\n            // SCALE ENTITY\n            Entity {\n                id: scaleEntity\n\n                Entity {\n                    id: axisCylinder\n                    components: [cylinderMesh, cylinderTransform, scaleMaterial, frontLayerComponent]\n\n                    CylinderMesh {\n                        id: cylinderMesh\n                        length: 0.5\n                        radius: axisContainer.lineRadius\n                        rings: 2\n                        slices: 16\n                    }\n                    Transform {\n                        id: cylinderTransform\n                        matrix: {\n                            const offset = cylinderMesh.length / 2 + centerSphereMesh.radius\n                            const m = Qt.matrix4x4()\n                            switch (axis) {\n                                case TransformGizmo.Axis.X: {\n                                    m.translate(Qt.vector3d(offset, 0, 0))\n                                    m.rotate(90, Qt.vector3d(0, 0, 1))\n                                    break\n                                }   \n                                case TransformGizmo.Axis.Y: {\n                                    m.translate(Qt.vector3d(0, offset, 0))\n                                    break\n                                }\n                                case TransformGizmo.Axis.Z: {\n                                    m.translate(Qt.vector3d(0, 0, offset))\n                                    m.rotate(90, Qt.vector3d(1, 0, 0))\n                                    break\n                                }\n                            }\n                            return m\n                        }\n                    }\n                }\n                Entity {\n                    id: axisScaleBox\n                    components: [cubeScaleMesh, cubeScaleTransform, scaleMaterial, scalePicker, frontLayerComponent]\n\n                    CuboidMesh {\n                        id: cubeScaleMesh\n                        property real edge: 0.06\n                        xExtent: edge\n                        yExtent: edge\n                        zExtent: edge\n                    }\n                    Transform {\n                        id: cubeScaleTransform\n                        matrix: {\n                            const offset = cylinderMesh.length + centerSphereMesh.radius\n                            const m = Qt.matrix4x4()\n                            switch(axis) {\n                                case TransformGizmo.Axis.X: {\n                                    m.translate(Qt.vector3d(offset, 0, 0))\n                                    m.rotate(90, Qt.vector3d(0, 0, 1))\n                                    break\n                                }\n                                case TransformGizmo.Axis.Y: {\n                                    m.translate(Qt.vector3d(0, offset, 0))\n                                    break\n                                }\n                                case TransformGizmo.Axis.Z: {\n                                    m.translate(Qt.vector3d(0, 0, offset))\n                                    m.rotate(90, Qt.vector3d(1, 0, 0))\n                                    break\n                                }\n                            }\n                            return m\n                        }\n                    }\n                }\n\n                PhongMaterial {\n                    id: scaleMaterial\n                    ambient: baseColor\n                }\n\n                TransformGizmoPicker { \n                    id: scalePicker\n                    mouseController: mouseHandler\n                    gizmoMaterial: scaleMaterial\n                    gizmoBaseColor: baseColor\n                    gizmoAxis: axis\n                    gizmoType: TransformGizmo.Type.SCALE\n\n                    onPickedChanged: function(picker) {\n                        // Save the current transformations of the OBJECT\n                        this.modelMatrix = Transformations3DHelper.modelMatrixToMatrices(objectTransform.matrix)\n                        // Compute a scale unit at picking time\n                        this.scaleUnit = Transformations3DHelper.computeScaleUnitFromModelMatrix(convertAxisEnum(gizmoAxis), gizmoDisplayTransform.matrix, camera, root.windowSize)\n                        // Prevent camera transformations\n                        root.pickedChanged(picker.isPressed)\n                    }\n                }\n            }\n\n            // TRANSLATION ENTITY\n            Entity {\n                id: positionEntity\n                components: [coneMesh, coneTransform, positionMaterial, positionPicker, frontLayerComponent]\n\n                ConeMesh {\n                    id: coneMesh\n                    bottomRadius: 0.035\n                    topRadius: 0.001\n                    hasBottomEndcap: true\n                    hasTopEndcap: true\n                    length: 0.13\n                    rings: 2\n                    slices: 8\n                }\n                Transform {\n                    id: coneTransform\n                    matrix: {\n                        const offset = cylinderMesh.length + centerSphereMesh.radius + 0.4\n                        const m = Qt.matrix4x4()\n                        switch (axis) {\n                            case TransformGizmo.Axis.X: {\n                                m.translate(Qt.vector3d(offset, 0, 0))\n                                m.rotate(-90, Qt.vector3d(0, 0, 1))\n                                break\n                            }\n                            case TransformGizmo.Axis.Y: {\n                                m.translate(Qt.vector3d(0, offset, 0))\n                                break\n                            }\n                            case TransformGizmo.Axis.Z: {\n                                m.translate(Qt.vector3d(0, 0, offset))\n                                m.rotate(90, Qt.vector3d(1, 0, 0))\n                                break\n                            }\n                        }\n                        return m\n                    }\n                }\n                PhongMaterial {\n                    id: positionMaterial\n                    ambient: baseColor\n                }\n\n                TransformGizmoPicker { \n                    id: positionPicker\n                    mouseController: mouseHandler\n                    gizmoMaterial: positionMaterial\n                    gizmoBaseColor: baseColor\n                    gizmoAxis: axis\n                    gizmoType: TransformGizmo.Type.TRANSLATION\n\n                    onPickedChanged: function(picker) {\n                        // Save the current transformations of the OBJECT\n                        this.modelMatrix = Transformations3DHelper.modelMatrixToMatrices(objectTransform.matrix)\n                        // Compute a scale unit at picking time\n                        this.scaleUnit = Transformations3DHelper.computeScaleUnitFromModelMatrix(convertAxisEnum(gizmoAxis), gizmoDisplayTransform.matrix, camera, root.windowSize)\n                        // Prevent camera transformations\n                        root.pickedChanged(picker.isPressed)\n                    }\n                }\n            }\n\n            // MOVE SURFACE ENTITY INSTANTIATOR => Forward/Backward axis directions\n            // The bounding box has 6 surfaces. Each of the three axes is pointing to a surface.\n            // These three surfaces have \"forward direction\". The three othersurfaces have \"backward direction\".\n            NodeInstantiator {\n                model: 2\n                active: !root.uniformScale  // Shouldn't be active for SfmTransform Gizmo node for example\n                \n                Entity {\n\n                    property int direction : {\n                        switch (index) {\n                            case 0: return TransformGizmo.Direction.Forward\n                            case 1: return TransformGizmo.Direction.Backward\n                        }                \n                    }\n\n                    // MOVE SURFACE ENTITY\n                    Entity {\n                        id: surfaceMoveEntity\n                        components: [surfaceMesh, surfaceTransform, surfaceMaterial, frontLayerComponent, surfacePicker]\n\n                        SphereMesh {\n                            id: surfaceMesh\n                            radius: 0.04\n                            rings: 8\n                            slices: 8\n                        }\n                        Transform {\n                            id: surfaceTransform\n                            matrix: {\n                                const m = Qt.matrix4x4()\n                                const sign = convertDirectionEnum(direction)\n                                const offset = 0.3\n                                switch (axis) {\n                                    case TransformGizmo.Axis.X: {\n                                        m.translate(Qt.vector3d(sign * (objectTransform.scale3D.x + offset) / gizmoDisplayTransform.scale3D.x, 0, 0))\n                                        m.rotate(-90, Qt.vector3d(0, 0, 1))\n                                        break\n                                    }\n\n                                    case TransformGizmo.Axis.Y: {\n                                        m.translate(Qt.vector3d(0, sign * (objectTransform.scale3D.y + offset) / gizmoDisplayTransform.scale3D.y, 0))\n                                        break\n                                    }\n                                    case TransformGizmo.Axis.Z: {\n                                        m.translate(Qt.vector3d(0, 0, sign * (objectTransform.scale3D.z + offset) / gizmoDisplayTransform.scale3D.z))\n                                        m.rotate(90, Qt.vector3d(1, 0, 0))\n                                        break\n                                    }\n                                }\n                                return m\n                            }\n                        }\n                        PhongMaterial {\n                            id: surfaceMaterial\n                            ambient: baseColor\n                        }\n\n                        TransformGizmoPicker { \n                            id: surfacePicker\n                            mouseController: mouseHandler\n                            gizmoMaterial: surfaceMaterial\n                            gizmoBaseColor: baseColor\n                            gizmoAxis: axis\n                            gizmoType: TransformGizmo.Type.ALL\n                            property var gizmoDirection: direction\n\n                            onPickedChanged: function (picker) {\n                                // Save the current transformations of the OBJECT\n                                this.modelMatrix = Transformations3DHelper.modelMatrixToMatrices(objectTransform.matrix)\n                                // Compute a scale unit at picking time\n                                this.scaleUnit = Transformations3DHelper.computeScaleUnitFromModelMatrix(convertAxisEnum(gizmoAxis), gizmoDisplayTransform.matrix, camera, root.windowSize)\n                                // Prevent camera transformations\n                                root.pickedChanged(picker.isPressed)\n                            }\n                        }\n                    }\n                }\n            }\n\n            // ROTATION ENTITY\n            Entity {\n                id: rotationEntity\n                components: [torusMesh, torusTransform, rotationMaterial, rotationPicker, frontLayerComponent]\n\n                TorusMesh {\n                    id: torusMesh\n                    radius: cylinderMesh.length + 0.25\n                    minorRadius: axisContainer.lineRadius\n                    slices: 8\n                    rings: 32\n                }\n                Transform {\n                    id: torusTransform\n                    matrix: {\n                        const scaleDiff = 2 * torusMesh.minorRadius + 0.01  // Just to make sure there is no face overlapping\n                        const m = Qt.matrix4x4()\n                        switch (axis) {\n                            case TransformGizmo.Axis.X: m.rotate(90, Qt.vector3d(0, 1, 0)); break\n                            case TransformGizmo.Axis.Y: m.rotate(90, Qt.vector3d(1, 0, 0)); m.scale(Qt.vector3d(1 - scaleDiff, 1 - scaleDiff, 1 - scaleDiff)); break\n                            case TransformGizmo.Axis.Z: m.scale(Qt.vector3d(1 - 2 * scaleDiff, 1 - 2 * scaleDiff, 1 - 2 * scaleDiff)); break\n                        }\n                        return m\n                    }\n                }\n                PhongMaterial {\n                    id: rotationMaterial\n                    ambient: baseColor\n                }\n\n                TransformGizmoPicker { \n                    id: rotationPicker\n                    mouseController: mouseHandler\n                    gizmoMaterial: rotationMaterial\n                    gizmoBaseColor: baseColor\n                    gizmoAxis: axis\n                    gizmoType: TransformGizmo.Type.ROTATION\n\n                    onPickedChanged: function(picker) {\n                        // Save the current transformations of the OBJECT\n                        this.modelMatrix = Transformations3DHelper.modelMatrixToMatrices(objectTransform.matrix)\n                        // No need to compute a scale unit for rotation\n                        // Prevent camera transformations\n                        root.pickedChanged(picker.isPressed)\n                    }\n                }\n            }\n        }\n    }\n}\n"
  },
  {
    "path": "meshroom/ui/qml/Viewer3D/TransformGizmoPicker.qml",
    "content": "import Qt3D.Core 2.6\nimport Qt3D.Render 2.6\nimport Qt3D.Input 2.6\nimport Qt3D.Extras 2.15\nimport Qt3D.Logic 2.6\nimport QtQuick\n\nObjectPicker {\n    id: root\n    property bool isPressed : false\n    property MouseHandler mouseController\n    property var gizmoMaterial\n    property color gizmoBaseColor\n    property int gizmoAxis\n    property int gizmoType\n    property point screenPoint\n    property var modelMatrix\n    property real scaleUnit\n    property int button\n    \n    signal pickedChanged(var picker)\n    \n    hoverEnabled: true\n\n    onPressed: function(mouse) {\n        mouseController.enabled = true\n        mouseController.objectPicker = this\n        root.isPressed = true\n        screenPoint = mouse.position\n        button = mouse.button\n        pickedChanged(this)\n    }\n    onEntered: {\n        gizmoMaterial.ambient = \"white\"\n    }\n    onExited: {\n        if (!isPressed)\n            gizmoMaterial.ambient = gizmoBaseColor\n    }\n    onReleased: {\n        gizmoMaterial.ambient = gizmoBaseColor\n        root.isPressed = false\n        mouseController.objectPicker = null\n        mouseController.enabled = false\n        pickedChanged(this)\n    }\n}\n"
  },
  {
    "path": "meshroom/ui/qml/Viewer3D/Viewer3D.qml",
    "content": "import QtQuick\nimport QtQuick.Controls\nimport QtQuick.Layouts\nimport QtQuick.Scene3D 2.6\nimport Qt3D.Core 2.6\nimport Qt3D.Render 2.6\nimport Qt3D.Extras 2.15\nimport Qt3D.Input 2.6 as Qt3DInput // to avoid clash with Controls2 Action\n\nimport Controls 1.0\nimport MaterialIcons 2.2\nimport Utils 1.0\n\n\nFocusScope {\n    id: root\n\n    property int renderMode: 2\n    readonly property alias library: mediaLibrary\n    readonly property alias mainCamera: mainCamera\n\n    readonly property vector3d defaultCamPosition: Qt.vector3d(12.0, 10.0, -12.0)\n    readonly property vector3d defaultCamUpVector: Qt.vector3d(-0.358979, 0.861550, 0.358979) // should be accurate, consistent with camera view center\n    readonly property vector3d defaultCamViewCenter: Qt.vector3d(0.0, 0.0, 0.0)\n\n    readonly property var viewpoint: _currentScene ? _currentScene.selectedViewpoint : null\n    readonly property bool doSyncViewpointCamera: Viewer3DSettings.syncViewpointCamera && (viewpoint && viewpoint.isReconstructed)\n\n    // Functions\n    function resetCameraPosition() {\n        mainCamera.position = defaultCamPosition\n        mainCamera.upVector = defaultCamUpVector\n        mainCamera.viewCenter = defaultCamViewCenter\n    }\n\n    function load(filepath, label = undefined) {\n        mediaLibrary.load(filepath, label)\n    }\n\n    /// View 'attribute' in the 3D Viewer. Media will be loaded if needed.\n    /// Returns whether the attribute can be visualized (matching type and extension).\n    function view(attribute) {\n        if (attribute.desc.type === \"File\"\n           && Viewer3DSettings.supportedExtensions.indexOf(Filepath.extension(attribute.value)) > - 1) {\n            mediaLibrary.view(attribute)\n            return true\n        }\n        return false\n    }\n\n    /// Solo (i.e display only) the given attribute.\n    function solo(attribute) {\n        mediaLibrary.solo(mediaLibrary.find(attribute))\n    }\n\n    function clear() {\n        mediaLibrary.clear()\n    }\n\n    SystemPalette { id: activePalette }\n\n    Scene3D {\n        id: scene3D\n        anchors.fill: parent\n        cameraAspectRatioMode: Scene3D.AutomaticAspectRatio  // vs. UserAspectRatio\n        hoverEnabled: true  // If true, will trigger positionChanged events in attached MouseHandler\n        aspects: [\"logic\", \"input\"]\n        focus: true\n\n        // We cannot use directly an ExifOrientedViewer since this component is not a Loader\n        // so we redefine the transform using the ExifOrientation utility functions\n        property var orientationTag: (doSyncViewpointCamera && root.viewpoint) ? root.viewpoint.orientation.toString() : \"1\"\n        transform: [\n            Rotation {\n                angle: ExifOrientation.rotation(scene3D.orientationTag)\n                origin.x: scene3D.width * 0.5\n                origin.y: scene3D.height * 0.5\n            },\n            Scale {\n                xScale: ExifOrientation.xscale(scene3D.orientationTag)\n                origin.x: scene3D.width * 0.5\n                origin.y: scene3D.height * 0.5\n            }\n        ]\n\n        Keys.onPressed: function(event) {\n            if (event.key === Qt.Key_F) {\n                resetCameraPosition()\n            } else if (Qt.Key_1 <= event.key && event.key < Qt.Key_1 + Viewer3DSettings.renderModes.length) {\n                Viewer3DSettings.renderMode = event.key - Qt.Key_1\n            } else {\n                event.accepted = false\n            }\n        }\n\n        Entity {\n            id: rootEntity\n\n            Camera {\n                id: mainCamera\n                projectionType: CameraLens.PerspectiveProjection\n                enabled: cameraSelector.camera == mainCamera\n                fieldOfView: 45\n                nearPlane : 0.01\n                farPlane : 10000.0\n                position: defaultCamPosition\n                upVector: defaultCamUpVector\n                viewCenter: defaultCamViewCenter\n                aspectRatio: width/height\n            }\n\n            ViewpointCamera {\n                id: viewpointCamera\n                enabled: cameraSelector.camera === camera\n                viewpoint: root.viewpoint\n                camera.aspectRatio: width/height\n            }\n\n            Entity {\n                components: [\n                    DirectionalLight{\n                        color: \"white\"\n                        worldDirection: Transformations3DHelper.getRotatedCameraViewVector(cameraSelector.camera.viewVector, cameraSelector.camera.upVector, directionalLightPane.lightPitchValue, directionalLightPane.lightYawValue).normalized()\n                    }\n                ]\n            }\n\n            TrackballGizmo {\n                beamRadius: 4.0/root.height\n                alpha: cameraController.moving ? 1.0 : 0.7\n                enabled: Viewer3DSettings.displayGizmo && cameraSelector.camera == mainCamera\n                xColor: Colors.red\n                yColor: Colors.green\n                zColor: Colors.blue\n                centerColor: Colors.sysPalette.highlight\n                transform: Transform {\n                    translation: mainCamera.viewCenter\n                    scale: 0.15 * mainCamera.viewCenter.minus(mainCamera.position).length()\n                }\n            }\n\n            DefaultCameraController {\n                id: cameraController\n                enabled: cameraSelector.camera == mainCamera\n\n                windowSize {\n                    width: root.width\n                    height: root.height\n                }\n                rotationSpeed: 16\n                trackballSize: 0.9\n\n                camera: mainCamera\n                focus: scene3D.activeFocus\n                onMousePressed: function(mouse) {\n                    scene3D.forceActiveFocus()\n                }\n                onMouseReleased: function(mouse, moved) {\n                    if (moving)\n                        return\n                    if (!moved && mouse.button === Qt.RightButton) {\n                        contextMenu.popup()\n                    }\n                }\n            }\n\n            components: [\n                RenderSettings {\n                    pickingSettings.pickMethod: PickingSettings.PrimitivePicking  // Enables point/edge/triangle picking\n                    pickingSettings.pickResultMode: PickingSettings.NearestPick\n                    renderPolicy: RenderSettings.Always\n\n                    activeFrameGraph: RenderSurfaceSelector {\n                        // Use the whole viewport\n                        Viewport {\n                            normalizedRect: Qt.rect(0.0, 0.0, 1.0, 1.0)\n                            CameraSelector {\n                                id: cameraSelector\n                                camera: doSyncViewpointCamera ? viewpointCamera.camera : mainCamera\n                                FrustumCulling {\n                                    ClearBuffers {\n                                        clearColor: \"transparent\"\n                                        buffers : ClearBuffers.ColorDepthBuffer\n                                        RenderStateSet {\n                                            renderStates: [\n                                                DepthTest { depthFunction: DepthTest.Less }\n                                            ]\n                                        }\n                                    }\n                                    LayerFilter {\n                                        filterMode: LayerFilter.DiscardAnyMatchingLayers\n                                        layers: Layer {id: drawOnFront}\n                                    }\n                                    LayerFilter {\n                                        filterMode: LayerFilter.AcceptAnyMatchingLayers\n                                        layers: [drawOnFront]\n                                        RenderStateSet {\n                                            renderStates: DepthTest { depthFunction: DepthTest.GreaterOrEqual }\n                                        }\n                                    }\n                                }\n                            }\n                        }\n                    }\n                },\n                Qt3DInput.InputSettings { }\n            ]\n\n            MediaLibrary {\n                id: mediaLibrary\n                renderMode: Viewer3DSettings.renderMode\n                // Picking to set focus point (camera view center)\n                // Only activate it when the 'Control' key is pressed\n                pickingEnabled: cameraController.pickingActive\n                camera: cameraSelector.camera\n\n                // Used for TransformGizmo in BoundingBox\n                sceneCameraController: cameraController\n                frontLayerComponent: drawOnFront\n                window: root\n\n                components: [\n                    Transform {\n                        id: transform\n                    }\n                ]\n\n                onClicked: function(pick) {\n                    if (pick.button === Qt.LeftButton) {\n                        mainCamera.viewCenter = pick.worldIntersection\n                    }\n                }\n\n            }\n            Locator3D { enabled: Viewer3DSettings.displayOrigin }\n            Grid3D { enabled: Viewer3DSettings.displayGrid }\n        }\n    }\n\n    // Image overlay when navigating reconstructed cameras\n    Loader {\n        id: imageOverlayLoader\n        anchors.fill: parent\n\n        active: doSyncViewpointCamera\n        visible: Viewer3DSettings.showViewpointImageOverlay\n\n        sourceComponent: ImageOverlay {\n            id: imageOverlay\n            source: root.viewpoint.undistortedImageSource\n            imageRatio: root.viewpoint.orientedImageSize.width * root.viewpoint.pixelAspectRatio / root.viewpoint.orientedImageSize.height\n            uvCenterOffset: root.viewpoint.uvCenterOffset\n            showFrame: Viewer3DSettings.showViewpointImageFrame\n            imageOpacity: Viewer3DSettings.viewpointImageOverlayOpacity\n        }\n    }\n\n    // Media loading overlay\n    // (Scene3D is frozen while a media is being loaded)\n    Rectangle {\n        anchors.fill: parent\n        visible: mediaLibrary.loading\n        color: Qt.darker(Colors.sysPalette.mid, 1.2)\n        opacity: 0.6\n        BusyIndicator {\n            anchors.centerIn: parent\n            running: parent.visible\n        }\n    }\n\n    FloatingPane {\n        visible: Viewer3DSettings.renderMode == 3\n        anchors.bottom: renderModesPanel.top\n        GridLayout {\n            columns: 2\n            rowSpacing: 0\n\n            RadioButton {\n                text: \"SHL File\"\n                autoExclusive: true\n                checked: true\n            }\n            TextField {\n                text: Viewer3DSettings.shlFile\n                selectByMouse: true\n                Layout.minimumWidth: 300\n                onEditingFinished: Viewer3DSettings.shlFile = text\n            }\n\n            RadioButton {\n                Layout.columnSpan: 2\n                autoExclusive: true\n                text: \"Normals\"\n                onCheckedChanged: Viewer3DSettings.displayNormals = checked\n            }\n\n        }\n    }\n\n    // Rendering modes\n    FloatingPane {\n        id: renderModesPanel\n        anchors.bottom: parent.bottom\n        padding: 4\n        Row {\n            Repeater {\n                model: Viewer3DSettings.renderModes\n\n                delegate: MaterialToolButton {\n                    text: modelData[\"icon\"]\n                    ToolTip.text: modelData[\"name\"] + \" (\" + (index+1) + \")\"\n                    font.pointSize: 11\n                    onClicked: Viewer3DSettings.renderMode = index\n                    checked: Viewer3DSettings.renderMode === index\n                    checkable: !checked  // Hack to disabled check on toggle\n                }\n            }\n        }\n    }\n\n    // Directional light controller\n    DirectionalLightPane {\n        id: directionalLightPane\n        anchors {\n            bottom: parent.bottom\n            right: parent.right\n            margins: 2\n        }\n        visible: Viewer3DSettings.displayLightController\n    }\n\n    // Menu\n    Menu {\n        id: contextMenu\n\n        MenuItem {\n            text: \"Fit All\"\n            onTriggered: mainCamera.viewAll()\n        }\n        MenuItem {\n            text: \"Reset View\"\n            onTriggered: resetCameraPosition()\n        }\n    }\n}\n"
  },
  {
    "path": "meshroom/ui/qml/Viewer3D/Viewer3DSettings.qml",
    "content": "pragma Singleton\nimport QtQuick\n\nimport MaterialIcons 2.2\n\n/**\n * Viewer3DSettings singleton gathers properties related to the 3D Viewer capabilities, state and display options.\n */\n\nItem {\n    readonly property Component sfmDataLoaderComp: Qt.createComponent(\"SfmDataLoader.qml\")\n    readonly property bool supportSfmData: sfmDataLoaderComp.status == Component.Ready\n    readonly property Component depthMapLoaderComp: Qt.createComponent(\"DepthMapLoader.qml\")\n    readonly property bool supportDepthMap: depthMapLoaderComp.status == Component.Ready\n\n    // Supported 3D files extensions\n    readonly property var supportedExtensions: {\n        var exts = [\".obj\", \".stl\", \".fbx\", \".gltf\", \".ply\"];\n        if (supportSfmData) {\n            exts.push(\".abc\")\n            exts.push(\".json\")\n            exts.push(\".sfm\")\n        }\n        if (supportDepthMap)\n            exts.push(\".exr\")\n\n        return exts;\n    }\n\n    // Available render modes\n    readonly property var renderModes: [  // Can't use ListModel because of MaterialIcons expressions\n                         {\"name\": \"Solid\", \"icon\": MaterialIcons.crop_din },\n                         {\"name\": \"Wireframe\", \"icon\": MaterialIcons.details },\n                         {\"name\": \"Textured\", \"icon\": MaterialIcons.texture },\n                         {\"name\": \"Spherical Harmonics\", \"icon\": MaterialIcons.brightness_7}\n                     ]\n    // Current render mode\n    property int renderMode: 2\n\n    // Spherical Harmonics file\n    property string shlFile: \"\"\n    // Whether to display normals\n    property bool displayNormals: false\n\n    // Rasterized point size\n    property real pointSize: 1.5\n    // Whether point size is fixed or view dependent\n    property bool fixedPointSize: false\n    property real cameraScale: 0.3\n    // Helpers display\n    property bool displayGrid: true\n    property bool displayGizmo: true\n    property bool displayOrigin: false\n    property bool displayLightController: false\n    // Camera\n    property bool syncViewpointCamera: false\n    property bool syncWithPickedViewId: false  // Sync active camera with picked view ID from sequence player if the setting is enabled\n    property bool viewpointImageOverlay: true\n    property real viewpointImageOverlayOpacity: 0.5\n    readonly property bool showViewpointImageOverlay: syncViewpointCamera && viewpointImageOverlay\n    property bool viewpointImageFrame: false\n    readonly property bool showViewpointImageFrame: syncViewpointCamera && viewpointImageFrame\n\n    // Cameras' resection IDs\n    property bool displayResectionIds: false\n    property int resectionIdCount: 0\n    property int resectionId: resectionIdCount\n    property var resectionGroups: []  // Number of cameras for each resection ID\n}\n"
  },
  {
    "path": "meshroom/ui/qml/Viewer3D/ViewpointCamera.qml",
    "content": "import QtQuick\nimport Qt3D.Core 2.6\nimport Qt3D.Render 2.6\n\n/**\n * ViewpointCamera sets up a Camera to match a Viewpoint's internal parameters.\n */\n\nEntity {\n    id: root\n\n    property variant viewpoint\n\n    property Camera camera: Camera {\n\n        nearPlane : 0.1\n        farPlane : 10000.0\n        viewCenter: Qt.vector3d(0.0, 0.0, -1.0)\n    }\n\n    components: [\n        Transform {\n            id: transform\n        }\n    ]\n\n    StateGroup {\n        states: [\n            State {\n                name: \"valid\"\n                when: root.viewpoint !== null\n                PropertyChanges {\n                    target: camera\n                    fieldOfView: root.viewpoint.fieldOfView\n                    upVector: root.viewpoint.upVector\n                }\n                PropertyChanges {\n                    target: transform\n                    rotation: root.viewpoint.rotation\n                    translation: root.viewpoint.translation\n                }\n            }\n        ]\n    }\n}\n"
  },
  {
    "path": "meshroom/ui/qml/Viewer3D/qmldir",
    "content": "module Viewer3D\n\nViewer3D 1.0 Viewer3D.qml\nsingleton Viewer3DSettings 1.0 Viewer3DSettings.qml\nDefaultCameraController 1.0 DefaultCameraController.qml\nLocator3D 1.0 Locator3D.qml\nGrid3D 1.0 Grid3D.qml\nInspector3D 1.0 Inspector3D.qml\n"
  },
  {
    "path": "meshroom/ui/qml/WorkspaceView.qml",
    "content": "import QtQuick\nimport QtQuick.Controls\nimport QtQuick.Layouts\n\nimport Controls 1.0\nimport MaterialIcons 2.2\nimport ImageGallery 1.0\nimport Viewer 1.0\nimport Viewer3D 1.0\n\n/**\n * WorkspaceView is an aggregation of Meshroom's main modules.\n *\n * It contains an ImageGallery, a 2D and a 3D viewer to manipulate and visualize scene data.\n */\n\nItem {\n    id: root\n\n    property variant currentScene: _currentScene\n    readonly property variant cameraInits: _currentScene ? _currentScene.cameraInits : null\n    property bool readOnly: false\n    property alias panel3dViewer: panel3dViewerLoader.item\n    readonly property Viewer2D viewer2D: viewer2D\n    readonly property alias imageGallery: imageGallery\n    readonly property TextViewer viewerText: textViewer\n    property alias mediaViewerTabIndex: mediaViewerPanel.currentTab\n\n    // Text Viewer occupies index 1 when Image Viewer is also visible, else index 0\n    readonly property int _textViewerTabIndex: settingsUILayout.showImageViewer ? 1 : 0\n\n    // Use settings instead of visible property as property changes are not propagated\n    visible: settingsUILayout.showImageGallery || settingsUILayout.showImageViewer || settingsUILayout.showViewer3D || settingsUILayout.showTextViewer\n\n    // Load a 3D media file in the 3D viewer\n    function load3DMedia(filepath, label = undefined) {\n        if (panel3dViewerLoader.active) {\n            panel3dViewerLoader.item.viewer3D.load(filepath, label)\n        }\n    }\n\n    Connections {\n        target: currentScene\n        function onGraphChanged() {\n            if (panel3dViewerLoader.active) {\n                panel3dViewerLoader.item.viewer3D.clear()\n            }\n        }\n        function onSfmChanged() { viewSfM() }\n        function onSfmReportChanged() { viewSfM() }\n    }\n    Component.onCompleted: viewSfM()\n\n    // Load the current scene's SfM file\n    function viewSfM() {\n        var activeNode = _currentScene.activeNodes ? _currentScene.activeNodes.get('sfm').node : null\n        if (!activeNode)\n            return\n        if (panel3dViewerLoader.active) {\n            panel3dViewerLoader.item.viewer3D.view(activeNode.attribute('output'))\n        }\n    }\n\n    SystemPalette { id: activePalette }\n\n    MSplitView {\n        id: mainSplitView\n        anchors.fill: parent\n        orientation: Qt.Horizontal\n\n        MSplitView {\n            id: leftSplitView\n            visible: settingsUILayout.showImageGallery\n            orientation: Qt.Vertical\n            SplitView.preferredWidth: imageGallery.defaultCellSize * 2 + 20\n            SplitView.minimumWidth: imageGallery.defaultCellSize\n\n            ImageGallery {\n                id: imageGallery\n                visible: settingsUILayout.showImageGallery\n                SplitView.fillHeight: true\n                readOnly: root.readOnly\n                cameraInits: root.cameraInits\n                cameraInit: currentScene ? currentScene.cameraInit : null\n                tempCameraInit: currentScene ? currentScene.tempCameraInit : null\n                cameraInitIndex: currentScene ? currentScene.cameraInitIndex : -1\n                onRemoveImageRequest: function(attribute) { currentScene.removeImage(attribute) }\n                onAllViewpointsCleared: currentScene.selectedViewId = \"-1\"\n                onFilesDropped: function(drop) {\n                    if (drop[\"meshroomScenes\"].length == 1) {\n                        ensureSaved(function() {\n                            if (currentScene.handleFilesUrl(drop, cameraInit)) {\n                                MeshroomApp.addRecentProjectFile(drop[\"meshroomScenes\"][0])\n                            }\n                        })\n                    } else {\n                        currentScene.handleFilesUrl(drop, cameraInit)\n                    }\n                }\n            }\n        }\n\n        TabPanel {\n            id: mediaViewerPanel\n            visible: settingsUILayout.showImageViewer || settingsUILayout.showTextViewer\n            implicitWidth: Math.round(parent.width * 0.35)\n            SplitView.fillWidth: true\n            SplitView.minimumWidth: 50\n\n            tabs: {\n                var t = []\n                if (settingsUILayout.showImageViewer) t.push(\"Image Viewer\")\n                if (settingsUILayout.showTextViewer) t.push(\"Text Viewer\")\n                return t\n            }\n\n            headerBar: RowLayout {\n                spacing: 4\n\n                // Loading indicator for image viewer\n                BusyIndicator {\n                    id: mediaViewerLoadingIndicator\n                    padding: 0\n                    implicitWidth: 12\n                    implicitHeight: 12\n                    running: settingsUILayout.showImageViewer && mediaViewerPanel.currentTab === 0 && viewer2D.loadingModules.length > 0\n                    visible: running\n                }\n                Label {\n                    visible: mediaViewerLoadingIndicator.visible\n                    text: \"Loading \" + viewer2D.loadingModules\n                    font.italic: true\n                }\n\n                MaterialToolButton {\n                    text: MaterialIcons.more_vert\n                    font.pointSize: 11\n                    padding: 2\n                    checkable: true\n                    checked: imageViewerMenu.visible\n                    visible: settingsUILayout.showImageViewer && mediaViewerPanel.currentTab === 0\n                    onClicked: imageViewerMenu.open()\n                    Menu {\n                        id: imageViewerMenu\n                        y: parent.height\n                        x: -width + parent.width\n                        Action {\n                            id: displayImageToolBarAction\n                            text: \"Display HDR Toolbar\"\n                            checkable: true\n                            checked: true\n                            enabled: viewer2D.useFloatImageViewer\n                        }\n                        Action {\n                            id: displayLensDistortionToolBarAction\n                            text: \"Display Lens Distortion Toolbar\"\n                            checkable: true\n                            checked: true\n                            enabled: viewer2D.useLensDistortionViewer\n                        }\n                        Action {\n                            id: displayPanoramaToolBarAction\n                            text: \"Display Panorama Toolbar\"\n                            checkable: true\n                            checked: true\n                            enabled: viewer2D.usePanoramaViewer\n                        }\n                        Action {\n                            id: displayImagePathAction\n                            text: \"Display Image Path\"\n                            checkable: true\n                            checked: true && !viewer2D.usePanoramaViewer\n                        }\n                        Action {\n                            id: enable8bitViewerAction\n                            text: \"Enable 8-bit Viewer\"\n                            checkable: true\n                            checked: MeshroomApp.default8bitViewerEnabled\n                        }\n                        Action {\n                            id: enableSequencePlayerAction\n                            text: \"Enable Sequence Player\"\n                            checkable: true\n                            checked: MeshroomApp.defaultSequencePlayerEnabled\n                        }\n                    }\n                }\n            }\n\n            Viewer2D {\n                id: viewer2D\n                anchors.fill: parent\n\n                visible: settingsUILayout.showImageViewer && mediaViewerPanel.currentTab === 0\n\n                viewIn3D: root.load3DMedia\n\n                DropArea {\n                    anchors.fill: parent\n                    keys: [\"text/uri-list\"]\n                    onDropped: function(drop) {\n                        viewer2D.loadExternal(drop.urls[0]);\n                    }\n                }\n                Rectangle {\n                    z: -1\n                    anchors.fill: parent\n                    color: Qt.darker(activePalette.base, 1.1)\n                }\n            }\n\n            TextViewer {\n                id: textViewer\n                anchors.fill: parent\n\n                visible: settingsUILayout.showTextViewer && mediaViewerPanel.currentTab === root._textViewerTabIndex\n\n                DropArea {\n                    anchors.fill: parent\n                    keys: [\"text/uri-list\"]\n                    onDropped: function(drop) {\n                        textViewer.source = drop.urls[0]\n                    }\n                }\n            }\n        }\n\n        Item {\n            id: viewer3DContainer\n            visible: settingsUILayout.showViewer3D\n            Layout.minimumWidth: 20\n            Layout.minimumHeight: 80\n            Layout.fillHeight: true\n            implicitWidth: Math.round(parent.width * 0.45)\n\n            Loader {\n                id: panel3dViewerLoader\n                active: settingsUILayout.showViewer3D\n                visible: active\n                anchors.fill: parent\n                sourceComponent: panel3dViewerComponent\n            }\n        }\n\n        Component {\n            id: panel3dViewerComponent\n            Panel {\n                id: panel3dViewer\n                title: \"3D Viewer\"\n\n                property alias viewer3D: c_viewer3D\n\n                MSplitView {\n                    id: c_viewer3DSplitView\n                    anchors.fill: parent\n                    orientation: Qt.Horizontal\n                    Viewer3D {\n                        id: c_viewer3D\n\n                        SplitView.fillWidth: true\n                        SplitView.minimumWidth: 50\n\n                        DropArea {\n                            anchors.fill: parent\n                            keys: [\"text/uri-list\"]\n                            onDropped: function(drop) {\n                                drop.urls.forEach(function(url) {\n                                    load3DMedia(url)\n                                })\n                            }\n                        }\n\n                        Connections {\n                            target: viewer2D\n                            function onSync3DSelectedChanged() {\n                                Viewer3DSettings.syncWithPickedViewId = viewer2D.sync3DSelected\n                            }\n                        }\n                    }\n                    \n                    // Inspector Panel\n                    Inspector3D {\n                        id: inspector3d\n                        SplitView.preferredWidth: 220\n                        SplitView.minimumWidth: 100\n\n                        mediaLibrary: c_viewer3D.library\n                        camera: c_viewer3D.mainCamera\n                        uigraph: currentScene\n                        onNodeActivated: _currentScene.setActiveNode(node)\n                    }\n                }\n            }\n        }\n    }\n}\n"
  },
  {
    "path": "meshroom/ui/qml/main.qml",
    "content": "import QtCore\n\nimport QtQuick\nimport QtQuick.Controls\nimport QtQuick.Dialogs\n\nimport Qt.labs.platform as Platform\n\nApplicationWindow {\n    id: _window\n\n    width: settingsGeneral.windowWidth\n    height: settingsGeneral.windowHeight\n    minimumWidth: 650\n    minimumHeight: 500\n    visible: true\n\n    property bool isClosing: false\n\n    title: {\n        var t = (_currentScene && _currentScene.graph && _currentScene.graph.filepath) ? _currentScene.graph.filepath : \"Untitled\"\n        if (_currentScene && !_currentScene.undoStack.clean)\n            t += \"*\"\n        t += \" - \" + Qt.application.name + \" \" + Qt.application.version\n        return t\n    }\n\n    onClosing: function(close) {\n        // Make sure document is saved before exiting application\n        close.accepted = false\n        if (!ensureNotComputing())\n            return\n        isClosing = true\n        ensureSaved(function() { Qt.quit() })\n    }\n\n    // QPalette is not convertible to QML palette (anymore)\n    Component.onCompleted: {\n        palette.alternateBase = _PaletteManager.alternateBase\n        palette.base = _PaletteManager.base\n        palette.button = _PaletteManager.button\n        palette.buttonText = _PaletteManager.buttonText\n        palette.highlight = _PaletteManager.highlight\n        palette.highlightedText = _PaletteManager.highlightedText\n        palette.link = _PaletteManager.link\n        palette.mid = _PaletteManager.mid\n        palette.shadow = _PaletteManager.shadow\n        palette.text = _PaletteManager.text\n        palette.toolTipBase = _PaletteManager.toolTipBase\n        palette.toolTipText = _PaletteManager.toolTipText\n        palette.window = _PaletteManager.window\n        palette.windowText = _PaletteManager.windowText\n\n        palette.disabled.buttonText = _PaletteManager.disabledButtonText\n        palette.disabled.highlight = _PaletteManager.disabledHighlight\n        palette.disabled.highlightedText = _PaletteManager.disabledHighlightedText\n        palette.disabled.text = _PaletteManager.disabledText\n        palette.disabled.windowText = _PaletteManager.disabledWindowText\n    }\n\n    SystemPalette { id: activePalette }\n    SystemPalette { id: disabledPalette; colorGroup: SystemPalette.Disabled }\n\n    Settings {\n        id: settingsGeneral\n        category: \"General\"\n        property int windowWidth: 1280\n        property int windowHeight: 720\n    }\n\n    Component.onDestruction: {\n        // Store main window dimensions in persisting Settings\n        settingsGeneral.windowWidth = _window.width\n        settingsGeneral.windowHeight = _window.height\n    }\n\n    function initFileDialogFolder(dialog, importImages = false) {\n        let folder = \"\"\n        let project = \"\"\n        try {\n            // The list of recent projects might be empty, hence the try/catch\n            project = MeshroomApp.recentProjectFiles[0][\"path\"]\n        } catch (error) {\n            console.info(\"The list of recent projects is currently empty.\")\n        }\n        let currentItem = mainStack.currentItem\n\n        if (currentItem instanceof Homepage) {\n            // From the homepage, take the folder from the most recent project (no prior check on its existence)\n            if (project != \"\" && Filepath.exists(project)) {\n                folder = Filepath.stringToUrl(Filepath.dirname(project))\n            }\n        } else {\n\n            if (currentItem.imagesFolder.toString() === \"\" && currentItem.workspaceView.imageGallery.galleryGrid.itemAtIndex(0) !== null) {\n                // Set the initial folder for the \"import images\" dialog if it has not been set already\n                currentItem.imagesFolder = Filepath.stringToUrl(Filepath.dirname(currentItem.workspaceView.imageGallery.galleryGrid.itemAtIndex(0).source))\n            }\n\n            if (_currentScene.graph && _currentScene.graph.filepath) {\n                // If the opened project has been saved, the dialog will open in the same folder\n                folder = Filepath.stringToUrl(Filepath.dirname(_currentScene.graph.filepath))\n            } else {\n                // If the currently opened project has not been saved, the dialog will open in the same\n                // folder as the most recent project if it exists; otherwise, it will not be set\n                if (project != \"\" && Filepath.exists(project)) {\n                    folder = Filepath.stringToUrl(Filepath.dirname(project))\n                }\n            }\n\n            // If the dialog that is being opened is the \"import images\" dialog, use the \"imagesFolder\" property\n            // which contains the last folder used to import images rather than the folder in which\n            // projects have been saved\n\n            const imageFolderPath = currentItem.imagesFolder.toString()\n            if (importImages && imageFolderPath !== \"\" && Filepath.exists(imageFolderPath)) {\n                folder = Filepath.stringToUrl(imageFolderPath)\n            }\n        }\n\n        dialog.folder = folder\n    }\n\n    Platform.FileDialog {\n        id: openFileDialog\n        title: \"Open File\"\n        nameFilters: [\"Meshroom Graphs (*.mg)\"]\n        onAccepted: {\n            if (mainStack.currentItem instanceof Homepage) {\n                mainStack.push(\"Application.qml\")\n            }\n            if (_currentScene.load(currentFile)) {\n                MeshroomApp.addRecentProjectFile(currentFile.toString())\n            }\n        }\n    }\n\n    // Check if document has been saved\n    function ensureSaved(callback)\n    {\n        var saved = _currentScene.undoStack.clean\n        if (!saved) {  // If current document is modified, open \"unsaved dialog\"\n            mainStack.currentItem.unsavedDialog.prompt(callback)\n        } else {  // Otherwise, directly call the callback\n            callback()\n        }\n        return saved\n    }\n\n    // Check and return whether no local computation is in progress\n    function ensureNotComputing()\n    {\n        if (_currentScene.computingLocally) {\n            // Open a warning dialog to ask for computation to be stopped\n            mainStack.currentItem.computingAtExitDialog.open()\n            return false\n        }\n        return true\n    }\n\n\n    Action {\n\n        shortcut: \"Ctrl+Shift+P\"\n        onTriggered: _PaletteManager.togglePalette()\n    }\n\n    StackView {\n        id: mainStack\n        anchors.fill: parent\n\n        Component.onCompleted: {\n            if (_currentScene.active) {\n                mainStack.push(\"Application.qml\")\n            } else {\n                mainStack.push(\"Homepage.qml\")\n            }\n        }\n\n        pushExit: Transition {}\n        pushEnter: Transition {}\n        popExit: Transition {}\n        popEnter: Transition {}\n        replaceEnter: Transition {}\n        replaceExit: Transition {}\n    }\n\n    background: MouseArea {\n        onPressed: {\n            forceActiveFocus();\n        }\n    }\n}\n"
  },
  {
    "path": "meshroom/ui/scene.py",
    "content": "import json\nimport logging\nimport math\nimport os\nfrom collections.abc import Iterable\nfrom multiprocessing.pool import ThreadPool\nfrom threading import Thread\nfrom typing import Callable\n\nfrom PySide6.QtCore import QObject, Slot, Property, Signal, QUrl, QSizeF, QPoint\nfrom PySide6.QtGui import QMatrix4x4, QMatrix3x3, QQuaternion, QVector3D, QVector2D\n\nimport meshroom.core\nimport meshroom.common\n\nfrom meshroom import multiview\nfrom meshroom.common.qt import QObjectListModel\nfrom meshroom.core import Version\nfrom meshroom.core.node import Node, CompatibilityNode, Status, Position, CompatibilityIssue\nfrom meshroom.core.taskManager import TaskManager\nfrom meshroom.core.evaluation import MathEvaluator\nfrom meshroom.core.plugins import NodePluginStatus\n\nfrom meshroom.ui import commands\nfrom meshroom.ui.graph import UIGraph\nfrom meshroom.ui.utils import makeProperty\nfrom meshroom.ui.components.filepath import FilepathHelper\n\n\nclass Message(QObject):\n    \"\"\" Simple structure wrapping a high-level message. \"\"\"\n\n    def __init__(self, title, text, detailedText=\"\", parent=None):\n        super().__init__(parent)\n        self._title = title\n        self._text = text\n        self._detailedText = detailedText\n\n    title = Property(str, lambda self: self._title, constant=True)\n    text = Property(str, lambda self: self._text, constant=True)\n    detailedText = Property(str, lambda self: self._detailedText, constant=True)\n\n\nclass ViewpointWrapper(QObject):\n    \"\"\"\n    ViewpointWrapper is a high-level object that wraps an input image in the context of a Scene.\n    It exposes the attributes of the image and its corresponding camera when reconstructed.\n    \"\"\"\n\n    initialParamsChanged = Signal()\n    sfmParamsChanged = Signal()\n    undistortedImageParamsChanged = Signal()\n    internalChanged = Signal()\n    principalPointCorrectedChanged = Signal()\n    uvCenterOffsetChanged = Signal()\n\n    def __init__(self, viewpointAttribute, scene):\n        \"\"\"\n        Viewpoint constructor\n\n        Args:\n            viewpointAttribute (GroupAttribute): viewpoint attribute\n            scene (Scene): owner scene of this Viewpoint\n        \"\"\"\n        super().__init__(parent=scene)\n        self._viewpoint = viewpointAttribute\n        self._scene = scene\n\n        # CameraInit\n        self._initialIntrinsics = None\n        # StructureFromMotion\n        self._T = None  # translation\n        self._R = None  # rotation\n        self._solvedIntrinsics = {}\n        self._reconstructed = False\n        # PrepareDenseScene\n        self._undistortedImagePath = ''\n        self._activeNode_PrepareDenseScene = self._scene.activeNodes.get(\"PrepareDenseScene\")\n        self._activeNode_ExportAnimatedCamera = self._scene.activeNodes.get(\"ExportAnimatedCamera\")\n        self._activeNode_ExportImages = self._scene.activeNodes.get(\"ExportImages\")\n        self._principalPointCorrected = False\n        self.principalPointCorrectedChanged.connect(self.uvCenterOffsetChanged)\n        self.sfmParamsChanged.connect(self.uvCenterOffsetChanged)\n\n        # update internally cached variables\n        self._updateInitialParams()\n        self._updateSfMParams()\n        self._updateUndistortedImageParams()\n\n        # trigger internal members updates when scene members changes\n        self._scene.cameraInitChanged.connect(self._updateInitialParams)\n        self._scene.sfmReportChanged.connect(self._updateSfMParams)\n        if self._activeNode_PrepareDenseScene:\n            self._activeNode_PrepareDenseScene.nodeChanged.connect(self._updateUndistortedImageParams)\n        if self._activeNode_ExportAnimatedCamera:\n            self._activeNode_ExportAnimatedCamera.nodeChanged.connect(self._updateUndistortedImageParams)\n        if self._activeNode_ExportImages:\n            self._activeNode_ExportImages.nodeChanged.connect(self._updateUndistortedImageParams)\n\n    def _updateInitialParams(self):\n        \"\"\" Update internal members depending on CameraInit. \"\"\"\n        if not self._scene.cameraInit:\n            self._initialIntrinsics = None\n            self._metadata = {}\n        else:\n            self._initialIntrinsics = self._scene.getIntrinsic(self._viewpoint)\n            try:\n                # When the viewpoint attribute has already been deleted, metadata.value becomes a PySide property (whereas a string is expected)\n                self._metadata = json.loads(self._viewpoint.metadata.value) if isinstance(self._viewpoint.metadata.value, str) and self._viewpoint.metadata.value else None\n            except Exception as exc:\n                logging.warning(f\"Failed to parse Viewpoint metadata: '{exc}', '{str(self._viewpoint.metadata.value)}'\")\n                self._metadata = {}\n            if not self._metadata:\n                self._metadata = {}\n        self.initialParamsChanged.emit()\n\n    def _updateSfMParams(self):\n        \"\"\" Update internal members depending on StructureFromMotion. \"\"\"\n        if not self._scene.sfm:\n            self._T = None\n            self._R = None\n            self._solvedIntrinsics = {}\n            self._reconstructed = False\n        else:\n            self._solvedIntrinsics = self._scene.getSolvedIntrinsics(self._viewpoint)\n            self._R, self._T = self._scene.getPoseRT(self._viewpoint)\n            self._reconstructed = self._R is not None\n        self.sfmParamsChanged.emit()\n\n    def _updateUndistortedImageParams(self):\n        \"\"\" Update internal members depending on PrepareDenseScene or ExportAnimatedCamera. \"\"\"\n        # undistorted image path\n        try:\n            if self._activeNode_ExportAnimatedCamera and self._activeNode_ExportAnimatedCamera.node:\n                self._undistortedImagePath = FilepathHelper.resolve(FilepathHelper, self._activeNode_ExportAnimatedCamera.node.outputImages.value, self._viewpoint)\n                self._principalPointCorrected = self._activeNode_ExportAnimatedCamera.node.correctPrincipalPoint.value\n            elif self._activeNode_PrepareDenseScene and self._activeNode_PrepareDenseScene.node:\n                self._undistortedImagePath = FilepathHelper.resolve(FilepathHelper, self._activeNode_PrepareDenseScene.node.undistorted.value, self._viewpoint)\n                self._principalPointCorrected = False\n            elif self._activeNode_ExportImages and self._activeNode_ExportImages.node:\n                self._undistortedImagePath = FilepathHelper.resolve(FilepathHelper, self._activeNode_ExportImages.node.undistorted.value, self._viewpoint)\n                self._principalPointCorrected = False\n            else:\n                self._undistortedImagePath = ''\n                self._principalPointCorrected = False\n        except Exception:\n            self._undistortedImagePath = ''\n            self._principalPointCorrected = False\n            logging.warning(\"Failed to retrieve undistorted images path.\")\n        self.undistortedImageParamsChanged.emit()\n        self.principalPointCorrectedChanged.emit()\n\n    # Get the underlying Viewpoint attribute wrapped by this Viewpoint.\n    attribute = Property(QObject, lambda self: self._viewpoint, constant=True)\n\n    @Property(type=\"QVariant\", notify=initialParamsChanged)\n    def initialIntrinsics(self):\n        \"\"\" Get viewpoint's initial intrinsics. \"\"\"\n        return self._initialIntrinsics\n\n    @Property(type=\"QVariant\", notify=initialParamsChanged)\n    def metadata(self):\n        \"\"\" Get image metadata. \"\"\"\n        return self._metadata\n\n    @Property(type=QSizeF, notify=initialParamsChanged)\n    def imageSize(self):\n        \"\"\" Get image size (width as the largest dimension). \"\"\"\n        if not self._initialIntrinsics:\n            return QSizeF(0, 0)\n        return QSizeF(self._initialIntrinsics.width.value, self._initialIntrinsics.height.value)\n\n    @Property(type=int, notify=initialParamsChanged)\n    def orientation(self):\n        \"\"\" Get image orientation based on its metadata. \"\"\"\n        return int(self.metadata.get(\"Orientation\", 1))\n\n    @Property(type=QSizeF, notify=initialParamsChanged)\n    def orientedImageSize(self):\n        \"\"\" Get image size taking into account its orientation. \"\"\"\n        if self.orientation in (5, 6, 7, 8):\n            return QSizeF(self.imageSize.height(), self.imageSize.width())\n        else:\n            return self.imageSize\n\n    @Property(type=bool, notify=sfmParamsChanged)\n    def isReconstructed(self):\n        \"\"\" Return whether this viewpoint corresponds to a reconstructed camera. \"\"\"\n        return self._reconstructed\n\n    @Property(type=\"QVariant\", notify=sfmParamsChanged)\n    def solvedIntrinsics(self):\n        return self._solvedIntrinsics\n\n    @Property(type=QVector3D, notify=sfmParamsChanged)\n    def translation(self):\n        \"\"\" Get the camera translation as a 3D vector. \"\"\"\n        if self._T is None:\n            return None\n        return QVector3D(self._T[0], -self._T[1], -self._T[2])\n\n    @Property(type=QQuaternion, notify=sfmParamsChanged)\n    def rotation(self):\n        \"\"\" Get the camera rotation as a quaternion. \"\"\"\n        if self._R is None:\n            return None\n\n        rot = QMatrix3x3([\n            self._R[0], -self._R[1], -self._R[2],\n            -self._R[3], self._R[4], self._R[5],\n            -self._R[6], self._R[7], self._R[8]]\n        )\n\n        return QQuaternion.fromRotationMatrix(rot)\n\n    @Property(type=QMatrix4x4, notify=sfmParamsChanged)\n    def pose(self):\n        \"\"\" Get the camera pose of 'viewpoint' as a 4x4 matrix. \"\"\"\n        if self._R is None or self._T is None:\n            return None\n\n        # convert transform matrix for Qt\n        return QMatrix4x4(\n            self._R[0], -self._R[1], -self._R[2], self._T[0],\n            -self._R[3], self._R[4], self._R[5], -self._T[1],\n            -self._R[6], self._R[7], self._R[8], -self._T[2],\n            0,          0,           0,           1\n        )\n\n    @Property(type=QVector3D, notify=sfmParamsChanged)\n    def upVector(self):\n        \"\"\" Get camera up vector. \"\"\"\n        return QVector3D(0.0, 1.0, 0.0)\n\n    @Property(type=QVector2D, notify=uvCenterOffsetChanged)\n    def uvCenterOffset(self):\n        \"\"\" Get UV offset corresponding to the camera principal point. \"\"\"\n        if not self.solvedIntrinsics or self._principalPointCorrected:\n            return None\n        pp = self.solvedIntrinsics[\"principalPoint\"]\n        # compute principal point offset in UV space\n        offset = QVector2D(float(pp[0]) / self.imageSize.width(), float(pp[1]) / self.imageSize.height())\n        return offset\n\n    @Property(type=float, notify=sfmParamsChanged)\n    def fieldOfView(self):\n        \"\"\" Get camera vertical field of view in degrees. \"\"\"\n        if not self.solvedIntrinsics:\n            return None\n        focalLength = self.solvedIntrinsics[\"focalLength\"]\n\n        #We assume that if the width is less than the weight\n        #It's because the image has been rotated and not\n        #because the sensor has some unusual shape\n        sensorWidth = self.solvedIntrinsics[\"sensorWidth\"]\n        sensorHeight = self.solvedIntrinsics[\"sensorHeight\"]\n        if self.imageSize.height() > self.imageSize.width():\n            sensorWidth, sensorHeight = sensorHeight, sensorWidth\n\n        if self.orientation in (5, 6, 7, 8):\n            return 2.0 * math.atan(float(sensorWidth) / (2.0 * float(focalLength))) * 180.0 / math.pi\n        else:\n            return 2.0 * math.atan(float(sensorHeight) / (2.0 * float(focalLength))) * 180.0 / math.pi\n\n    @Property(type=float, notify=sfmParamsChanged)\n    def pixelAspectRatio(self):\n        \"\"\" Get camera pixel aspect ratio. \"\"\"\n        if not self.solvedIntrinsics:\n            return 1.0\n\n        return float(self.solvedIntrinsics[\"pixelRatio\"])\n\n    @Property(type=QUrl, notify=undistortedImageParamsChanged)\n    def undistortedImageSource(self):\n        \"\"\" Get path to undistorted image source if available. \"\"\"\n        return QUrl.fromLocalFile(self._undistortedImagePath)\n\n\ndef parseSfMJsonFile(sfmJsonFile):\n    \"\"\"\n    Parse the SfM Json file and return views, poses and intrinsics as three dicts with viewId, poseId and intrinsicId as keys.\n    \"\"\"\n    if not os.path.exists(sfmJsonFile):\n        return {}, {}, {}\n\n    with open(sfmJsonFile) as jsonFile:\n        report = json.load(jsonFile)\n\n    views = dict()\n    poses = dict()\n    intrinsics = dict()\n\n    for view in report['views']:\n        views[view['viewId']] = view\n\n    if \"poses\" in report:\n        for pose in report['poses']:\n            poses[pose['poseId']] = pose['pose']\n\n    for intrinsic in report['intrinsics']:\n        intrinsics[intrinsic['intrinsicId']] = intrinsic\n\n    return views, poses, intrinsics\n\n\nclass ActiveNode(QObject):\n    \"\"\"\n    Hold one active node for a given NodeType.\n    \"\"\"\n    def __init__(self, nodeType, parent=None):\n        super().__init__(parent)\n        self.nodeType = nodeType\n        self._node = None\n\n    nodeChanged = Signal()\n    node = makeProperty(QObject, \"_node\", nodeChanged, resetOnDestroy=True)\n\n\nclass Scene(UIGraph):\n    \"\"\"\n    Specialization of a UIGraph designed to manage a Meshroom scene\n    \"\"\"\n    activeNodeCategories = {\n        # All nodes generating a sfm scene (3D reconstruction or panorama)\n        \"sfm\": [\"StructureFromMotion\", \"GlobalSfM\", \"PanoramaEstimation\", \"SfMTransform\",\n                \"SfMAlignment\", \"SfMExpanding\", \"SfMBootstraping\"],\n        # All nodes generating a sfmData file\n        \"sfmData\": [\"CameraInit\", \"DistortionCalibration\", \"StructureFromMotion\", \"GlobalSfM\",\n                    \"PanoramaEstimation\", \"SfMTransfer\", \"SfMTransform\", \"SfMAlignment\",\n                    \"ApplyCalibration\", \"SfMExpanding\", \"SfMBootstraping\"],\n        # All nodes generating depth map files\n        \"allDepthMap\": [\"DepthMap\", \"DepthMapFilter\"],\n        # Nodes that can be used to provide features folders to the UI\n        \"featureProvider\": [\"FeatureExtraction\", \"FeatureMatching\", \"StructureFromMotion\", \"RomaReducer\"],\n        # Nodes that can be used to provide matches folders to the UI\n        \"matchProvider\": [\"FeatureMatching\", \"StructureFromMotion\", \"RomaReducer\"],\n        # Nodes that can be used to provide tracks files to the UI\n        \"trackProvider\": [\"TracksBuilding\", \"SfMBootstraping\", \"SfMExpanding\"]\n    }\n    # Nodes accessed from the UI\n    uiNodes = [\n        \"LdrToHdrMerge\",\n        \"LdrToHdrCalibration\",\n        \"ImageProcessing\",\n        \"PhotometricStereo\",\n        \"PanoramaInit\",\n        \"ColorCheckerDetection\",\n        \"SphereDetection\",\n    ]\n\n    def __init__(self, undoStack: commands.UndoStack, taskManager: TaskManager, defaultPipeline: str=\"\", parent: QObject=None):\n        super().__init__(undoStack, taskManager, parent)\n\n        # initialize member variables for key steps of the 3D reconstruction pipeline\n        self._active = False\n        self._activeNodes = meshroom.common.DictModel(keyAttrName=\"nodeType\")\n        self.initActiveNodes()\n\n        # initialize activeAttributes (attributes currently visible in some viewers)\n        self._displayedAttr2D = None\n        self._displayedAttrs3D = meshroom.common.ListModel()\n\n        # - CameraInit\n        self._cameraInit = None                            # current CameraInit node\n        self._cameraInits = QObjectListModel(parent=self)  # all CameraInit nodes\n        self._buildingIntrinsics = False\n        self.intrinsicsBuilt.connect(self.onIntrinsicsAvailable)\n\n        self.cameraInitChanged.connect(self.onCameraInitChanged)\n\n        self._tempCameraInit = None\n\n        self.importImagesFailed.connect(self.onImportImagesFailed)\n\n        # - SfM\n        self._sfm = None\n        self._views = None\n        self._poses = None\n        self._solvedIntrinsics = None\n        self._selectedViewId = None\n        self._selectedViewpoint = None\n        self._pickedViewId = None\n\n        self._currentViewPath = \"\"\n\n        self._workerThreads = ThreadPool(processes=1)\n\n        # react to internal graph changes to update those variables\n        self.graphChanged.connect(self.onGraphChanged)\n\n        # Connect the pluginsReloaded signal to the onPluginsReloaded function\n        self.pluginsReloaded.connect(self._onPluginsReloaded)\n\n        self.setDefaultPipeline(defaultPipeline)\n\n    def __del__(self):\n        self._workerThreads.terminate()\n        self._workerThreads.join()\n\n    def setActive(self, active):\n        self._active = active\n\n    @Slot()\n    def clear(self):\n        self.clearActiveNodes()\n        super().clear()\n        self.setActive(False)\n\n    def setDefaultPipeline(self, defaultPipeline):\n        self._defaultPipeline = defaultPipeline\n\n    def setSubmitLabel(self, submitLabel):\n        self.submitLabel = submitLabel\n\n    def initActiveNodes(self):\n        # Create all possible entries\n        for category, _ in self.activeNodeCategories.items():\n            self._activeNodes.add(ActiveNode(category, parent=self))\n        # For all nodes declared to be accessed by the UI\n        usedNodeTypes = {j for i in self.activeNodeCategories.values() for j in i}\n        allLoadedNodeTypes = set(meshroom.core.pluginManager.getRegisteredNodePlugins().keys())\n        allUiNodes = set(self.uiNodes) | usedNodeTypes | allLoadedNodeTypes\n\n        for nodeType in allUiNodes:\n            self._activeNodes.add(ActiveNode(nodeType, parent=self))\n\n    def clearActiveNodes(self):\n        for key in self._activeNodes.keys():\n            self._activeNodes.get(key).node = None\n\n    def onCameraInitChanged(self):\n        if self._cameraInit is None:\n            return\n        # Update active nodes when CameraInit changes\n        nodes = self._graph.dfsOnDiscover(startNodes=[self._cameraInit], reverse=True)[0]\n        self.setActiveNodes(nodes)\n\n    @Slot()\n    def reloadPlugins(self):\n        \"\"\" Launch _reloadPlugins in a worker thread to avoid blocking the ui. \"\"\"\n        self._workerThreads.apply_async(func=self._reloadPlugins, args=())\n\n    def _reloadPlugins(self):\n        \"\"\"\n        Reload all the NodePlugins from all the registered plugins.\n        The nodes in the graph will be updated to match the changes in the description, if\n        there was any.\n        \"\"\"\n        reloadedNodes: list[str] = []\n        errorNodes: list[str] = []\n        for plugin in meshroom.core.pluginManager.getPlugins().values():\n            for node in plugin.nodes.values():\n                if node.reload():\n                    reloadedNodes.append(node.nodeDescriptor.__name__)\n                else:\n                    if node.status == NodePluginStatus.DESC_ERROR or node.status == NodePluginStatus.ERROR:\n                        errorNodes.append(node.nodeDescriptor.__name__)\n\n        self.pluginsReloaded.emit(reloadedNodes, errorNodes)\n\n    @Slot(list)\n    def _onPluginsReloaded(self, reloadedNodes: list, errorNodes: list):\n        self._graph.reloadNodePlugins(reloadedNodes)\n        if len(errorNodes) > 0:\n            self.parent().showMessage(f\"Some plugins failed to reload: {', '.join(errorNodes)}\", \"error\")\n        else:\n            self.parent().showMessage(\"Plugins reloaded!\", \"ok\")\n\n    @Slot(result=bool)\n    @Slot(str, result=bool)\n    def new(self, pipeline=None):\n        \"\"\" Create a new pipeline. \"\"\"\n        p = pipeline if pipeline is not None else self._defaultPipeline\n        # Lower the input and the dictionary keys to make sure that all input types can be found:\n        # - correct pipeline name but the case does not match (e.g. panoramaHDR instead of panoramaHdr)\n        # - lowercase pipeline name given through the \"New Pipeline\" menu\n        loweredPipelineTemplates = {k.lower(): v for k, v in meshroom.core.pipelineTemplates.items()}\n        filepath = loweredPipelineTemplates.get(p.lower(), p)\n        return self._loadWithErrorReport(self.initFromTemplate, filepath)\n\n    def _initFromTemplateWithCopyOutputs(self, filepath):\n        self.initFromTemplate(filepath, copyOutputs=True)\n\n    @Slot(result=bool)\n    @Slot(str, result=bool)\n    def newWithCopyOutputs(self, pipeline=None):\n        \"\"\" Create a new pipeline with all the \"CopyFiles\" nodes included if the provided template has any. \"\"\"\n        p = pipeline if pipeline is not None else self._defaultPipeline\n        loweredPipelineTemplates = {k.lower(): v for k, v in meshroom.core.pipelineTemplates.items()}\n        filepath = loweredPipelineTemplates.get(p.lower(), p)\n        return self._loadWithErrorReport(self._initFromTemplateWithCopyOutputs, filepath)\n\n\n    @Slot(str, result=bool)\n    @Slot(QUrl, result=bool)\n    def load(self, url):\n        if isinstance(url, QUrl):\n            # depending how the QUrl has been initialized,\n            # toLocalFile() may return the local path or an empty string\n            localFile = url.toLocalFile() or url.toString()\n        else:\n            localFile = url\n        return self._loadWithErrorReport(self.loadGraph, localFile)\n\n    def _loadWithErrorReport(self, loadFunction: Callable[[str], None], filepath: str):\n        logging.info(f\"Load project file: '{filepath}'\")\n        try:\n            loadFunction(filepath)\n            # warn about pre-release projects being automatically upgraded\n            if Version(self._graph.fileReleaseVersion).major == \"0\":\n                self.warning.emit(Message(\n                    \"Automatic project upgrade\",\n                    \"This project was created with an older version of Meshroom and has been automatically upgraded.\\n\"\n                    \"Data might have been lost in the process.\",\n                    \"Open it with the corresponding version of Meshroom to recover your data.\"\n                ))\n            self.setActive(True)\n            return True\n        except FileNotFoundError:\n            self.error.emit(\n                Message(\n                    \"No Such File\",\n                    f\"Error While Loading '{os.path.basename(filepath)}': No Such File.\",\n                    \"\"\n                )\n            )\n            logging.error(f\"Error while loading '{filepath}': No Such File.\")\n        except Exception:\n            import traceback\n            trace = traceback.format_exc()\n            self.error.emit(\n                Message(\n                    \"Error While Loading Project File\",\n                    f\"An unexpected error has occurred while loading file: '{os.path.basename(filepath)}'\",\n                    trace\n                )\n            )\n            logging.error(f\"Error while loading '{filepath}'.\")\n            logging.error(trace)\n\n        return False\n\n    def onGraphChanged(self):\n        \"\"\" React to the change of the internal graph. \"\"\"\n        self.selectedViewId = \"-1\"\n        self.tempCameraInit = None\n        self.updateCameraInits()\n        self.resetActiveNodePerCategory()\n        self.sfm = self.lastSfmNode()\n        if not self._graph:\n            return\n\n        # TODO: listen specifically for cameraInit creation/deletion\n        self._graph.nodes.countChanged.connect(self.updateCameraInits)\n\n    @Slot(QObject)\n    def getViewpoints(self):\n        \"\"\" Return the Viewpoints model. \"\"\"\n        # TODO: handle multiple Viewpoints models\n        if self.tempCameraInit:\n            return self.tempCameraInit.viewpoints.value\n        elif self._cameraInit:\n            return self._cameraInit.viewpoints.value\n        else:\n            return QObjectListModel(parent=self)\n\n    def updateCameraInits(self):\n        cameraInits = self._graph.nodesOfType(\"CameraInit\", sortedByIndex=True)\n        if set(self._cameraInits.objectList()) == set(cameraInits):\n            return\n        self._cameraInits.setObjectList(cameraInits)\n\n        if self.cameraInit is None or self.cameraInit not in cameraInits:\n            self.cameraInit = cameraInits[0] if cameraInits else None\n\n        # Manually emit the signal to ensure the active CameraInit index is always up-to-date in the UI\n        self.cameraInitChanged.emit()\n\n    def getCameraInitIndex(self):\n        if not self._cameraInit:\n            # No CameraInit node\n            return -1\n        if not self._cameraInit.graph:\n            # The CameraInit node is a temporary one not attached to a graph\n            return -1\n        return self._cameraInits.indexOf(self._cameraInit)\n\n    def setCameraInitIndex(self, idx):\n        camInit = self._cameraInits[idx] if self._cameraInits else None\n        self.cameraInit = camInit\n        # Update the active viewpoint accordingly\n        if self.viewpoints:\n            self.setSelectedViewId(self.viewpoints[0].viewId.value)\n\n    def setCameraInitNode(self, node):\n        if self._cameraInit == node:\n            return\n        self.setCameraInitIndex(self._cameraInits.indexOf(node))\n\n    @Slot()\n    def clearTempCameraInit(self):\n        self.tempCameraInit = None\n\n    @Slot(QObject, str)\n    def setupTempCameraInit(self, node, attrName):\n        if not node or not attrName:\n            self.tempCameraInit = None\n            return\n        sfmFile = node.attribute(attrName).value\n        if not sfmFile or not os.path.isfile(sfmFile):\n            self.tempCameraInit = None\n            return\n        nodeDesc = meshroom.core.pluginManager.getRegisteredNodePlugin(\"CameraInit\").nodeDescriptor()\n        views, intrinsics = nodeDesc.readSfMData(sfmFile)\n        tmpCameraInit = Node(\"CameraInit\", viewpoints=views, intrinsics=intrinsics)\n        tmpCameraInit.locked = True\n        self.tempCameraInit = tmpCameraInit\n        rootNode = self.graph.dfsOnFinish([node])[0][0]\n        if rootNode.nodeType == \"CameraInit\":\n            self.setCameraInitNode(rootNode)\n\n    @Slot(QObject, result=QVector3D)\n    def getAutoFisheyeCircle(self, panoramaInit):\n        if not panoramaInit or not panoramaInit.isComputed:\n            return QVector3D(0.0, 0.0, 0.0)\n        if not panoramaInit.attribute(\"estimateFisheyeCircle\").value:\n            return QVector3D(0.0, 0.0, 0.0)\n\n        sfmFile = panoramaInit.attribute('outSfMData').value\n        if not os.path.exists(sfmFile):\n            return QVector3D(0.0, 0.0, 0.0)\n        # skip decoding errors to avoid potential exceptions due to non utf-8 characters in images metadata\n        with open(sfmFile, encoding='utf-8', errors='ignore') as f:\n            data = json.load(f)\n\n        intrinsics = data.get('intrinsics', [])\n        if len(intrinsics) == 0:\n            return QVector3D(0.0, 0.0, 0.0)\n        intrinsic = intrinsics[0]\n\n        res = QVector3D(float(intrinsic.get(\"fisheyeCircleCenterX\", 0.0)) - float(intrinsic.get(\"width\", 0.0)) * 0.5,\n                        float(intrinsic.get(\"fisheyeCircleCenterY\", 0.0)) - float(intrinsic.get(\"height\", 0.0)) * 0.5,\n                        float(intrinsic.get(\"fisheyeCircleRadius\", 0.0)))\n        return res\n\n    def lastSfmNode(self):\n        \"\"\" Retrieve the last SfM node from the initial CameraInit node. \"\"\"\n        return self.lastNodeOfType(self.activeNodeCategories['sfm'], self._cameraInit, Status.SUCCESS)\n\n    def lastNodeOfType(self, nodeTypes, startNode, preferredStatus=None):\n        \"\"\"\n        Returns the last node of the given type starting from 'startNode'.\n        If 'preferredStatus' is specified, the last node with this status will be considered in priority.\n\n        Args:\n            nodeTypes (str list): the node types\n            startNode (Node): the node to start from\n            preferredStatus (Status): (optional) the node status to prioritize\n\n        Returns:\n            Node: the node matching the input parameters or None\n        \"\"\"\n        if not startNode:\n            return None\n        nodes = self._graph.dfsOnDiscover(startNodes=[startNode],\n                                          filterTypes=nodeTypes, reverse=True)[0]\n        if not nodes:\n            return None\n        # order the nodes according to their depth in the graph, then according to their name\n        nodes.sort(key=lambda n: (n.depth, n.name))\n        node = nodes[-1]\n        if preferredStatus:\n            node = next((n for n in reversed(nodes)\n                         if n.getGlobalStatus() == preferredStatus), node)\n        return node\n\n    @Slot(result=\"QVariantList\")\n    def allImagePaths(self):\n        \"\"\" Get all image paths in the scene. \"\"\"\n        return [vp.path.value for node in self._cameraInits for vp in node.viewpoints.value]\n\n    def allViewIds(self):\n        \"\"\" Get all view Ids involved in the scene. \"\"\"\n        return [vp.viewId.value for node in self._cameraInits for vp in node.viewpoints.value]\n\n    @Slot(\"QVariantMap\", result=bool)\n    @Slot(\"QVariantMap\", Node, result=bool)\n    @Slot(\"QVariantMap\", Node, \"QPoint\", result=bool)\n    def handleFilesUrl(self, filesByType, cameraInit=None, position=None):\n        \"\"\" Handle drop events aiming to add images to the scene.\n        This method allows to reduce process time by doing it on Python side.\n\n        Args:\n            {images, videos, panoramaInfo, meshroomScenes, otherFiles}: Map containing the\n                lists of paths for recognized images, videos, Meshroom scenes and other files.\n            Node: cameraInit node used to add new images to it\n            QPoint: position to locate the node (usually the mouse position)\n        \"\"\"\n        if filesByType[\"images\"]:\n            if cameraInit is None:\n                if not self._cameraInits:\n                    if isinstance(position, QPoint):\n                        p = Position(position.x(), position.y())\n                    else:\n                        p = position\n                    cameraInit = self.addNewNode(\"CameraInit\", position=p)\n                else:\n                    boundingBox = self.layout.boundingBox()\n                    if not position:\n                        p = Position(boundingBox[0], boundingBox[1] + boundingBox[3])\n                    elif isinstance(position, QPoint):\n                        p = Position(position.x(), position.y())\n                    else:\n                        p = position\n                    cameraInit = self.addNewNode(\"CameraInit\", position=p)\n            self._workerThreads.apply_async(func=self.importImagesSync,\n                                            args=(filesByType[\"images\"], cameraInit,))\n        if filesByType[\"videos\"]:\n            if self.nodes:\n                boundingBox = self.layout.boundingBox()\n                p = Position(boundingBox[0], boundingBox[1] + boundingBox[3])\n            else:\n                p = position\n            keyframeNode = self.addNewNode(\"KeyframeSelection\", position=p)\n            keyframeNode.inputPaths.value = filesByType[\"videos\"]\n            if len(filesByType[\"videos\"]) == 1:\n                newVideoNodeMessage = f\"New node '{keyframeNode.getLabel()}' added for the input video.\"\n            else:\n                newVideoNodeMessage = f\"New node '{keyframeNode.getLabel()}' added for a rig of {len(filesByType['videos'])} synchronized cameras.\"\n            self.info.emit(\n                Message(\n                    \"Video Input\",\n                    newVideoNodeMessage,\n                    \"Warning: You need to manually compute the KeyframeSelection node \\n\"\n                    \"and then reimport the created images into Meshroom for the reconstruction.\\n\\n\"\n                    \"If you know the Camera Make/Model, it is highly recommended to declare \"\n                    \"them in the Node.\"\n                ))\n\n        if filesByType[\"panoramaInfo\"]:\n            if len(filesByType[\"panoramaInfo\"]) > 1:\n                self.error.emit(\n                    Message(\n                        \"Multiple XML files in input\",\n                        \"Ignore the XML Panorama files:\\n\\n'{}'.\".format(',\\n'.join(filesByType[\"panoramaInfo\"])),\n                        \"\",\n                    ))\n            else:\n                panoramaInitNodes = self.graph.nodesOfType(\"PanoramaInit\")\n                for panoramaInfoFile in filesByType[\"panoramaInfo\"]:\n                    for panoramaInitNode in panoramaInitNodes:\n                        panoramaInitNode.attribute(\"initializeCameras\").value = \"File\"\n                        panoramaInitNode.attribute(\"config\").value = panoramaInfoFile\n                if panoramaInitNodes:\n                    self.info.emit(\n                        Message(\n                            \"Panorama XML\",\n                            \"XML file declared on PanoramaInit node\",\n                            f\"XML file '{','.join(filesByType['panoramaInfo'])}' set on node '{','.join([n.getLabel() for n in panoramaInitNodes])}'\",\n                        ))\n                else:\n                    self.error.emit(\n                        Message(\n                            \"No PanoramaInit Node\",\n                            f\"No PanoramaInit Node to set the Panorama file:\\n'{','.join(filesByType['panoramaInfo'])}'.\",\n                            \"\",\n                        ))\n\n        if filesByType[\"meshroomScenes\"]:\n            if len(filesByType[\"meshroomScenes\"]) > 1:\n                self.error.emit(\n                    Message(\n                    \"Too Many Meshroom Scenes\",\n                    \"A single Meshroom scene (.mg file) can be imported at once.\"\n                    )\n                )\n            else:\n                return self.load(filesByType[\"meshroomScenes\"][0])\n\n\n\n        if not filesByType[\"images\"] and not filesByType[\"videos\"] and not filesByType[\"panoramaInfo\"] and not filesByType[\"meshroomScenes\"]:\n            if filesByType[\"other\"]:\n                extensions = {os.path.splitext(url)[1] for url in filesByType[\"other\"]}\n                self.error.emit(\n                    Message(\n                        \"No Recognized Input File\",\n                        f\"No recognized input file in the {len(filesByType['other'])} dropped files\",\n                        \"Unknown file extensions: \" + ', '.join(extensions)\n                    )\n                )\n\n        # As the boolean is introduced to check if the project is loaded or not, the return value is added to the function.\n        # The default value is False, which means the project is not loaded.\n        return False\n\n    @Slot(\"QList<QUrl>\", result=\"QVariantMap\")\n    def getFilesByTypeFromDrop(self, urls):\n        \"\"\"\n        Given a list of filepaths, sort them into distinct categories and return a map for all\n        these categories.\n\n        Args:\n            urls: list of filepaths\n\n        Returns:\n            {images, videos, panoramaInfo, meshroomScenes, otherFiles}: Map containing the lists of paths for\n            recognized images, videos, Meshroom scenes and other files.\n        \"\"\"\n        # Build the list of images paths\n        filesByType = multiview.FilesByType()\n        for url in urls:\n            localFile = url.toLocalFile()\n            if os.path.isdir(localFile):  # get folder content\n                filesByType.extend(multiview.findFilesByTypeInFolder(localFile))\n            else:\n                filesByType.addFile(localFile)\n        return {\"images\": filesByType.images,\n                \"videos\": filesByType.videos,\n                \"panoramaInfo\": filesByType.panoramaInfo,\n                \"meshroomScenes\": filesByType.meshroomScenes,\n                \"other\": filesByType.other}\n\n    def importImagesFromFolder(self, path, recursive=False):\n        \"\"\"\n\n        Args:\n            path: A path to a folder or file or a list of files/folders\n            recursive: List files in folders recursively.\n\n        \"\"\"\n        logging.debug(\"importImagesFromFolder: \" + str(path))\n        filesByType = multiview.findFilesByTypeInFolder(path, recursive)\n        if not self.cameraInit:\n            # Create a CameraInit node if none exists\n            self.cameraInit = self.addNewNode(\"CameraInit\")\n        if filesByType.images:\n            self._workerThreads.apply_async(func=self.importImagesSync, args=(filesByType.images, self.cameraInit,))\n\n    @Slot(\"QVariant\")\n    def importImagesUrls(self, imagePaths, recursive=False):\n        paths = []\n        for imagePath in imagePaths:\n            if isinstance(imagePath, (QUrl)):\n                p = imagePath.toLocalFile()\n                if not p:\n                    p = imagePath.toString()\n            else:\n                p = imagePath\n            paths.append(p)\n        self.importImagesFromFolder(paths)\n\n    def importImagesSync(self, images, cameraInit):\n        \"\"\" Add the given list of images to the scene. \"\"\"\n        try:\n            self.buildIntrinsics(cameraInit, images)\n        except Exception as exc:\n            self.importImagesFailed.emit(str(exc))\n\n    @Slot()\n    def onImportImagesFailed(self, msg):\n        self.error.emit(\n            Message(\n                \"Failed to Import Images\",\n                \"A corrupted image in the import set or an installation error may have caused this issue.\",\n                \"\"  # msg\n            )\n        )\n\n    def buildIntrinsics(self, cameraInit, additionalViews, rebuild=False):\n        \"\"\"\n        Build up-to-date intrinsics and views based on already loaded + additional images.\n        Does not modify the graph, can be called outside the main thread.\n        Emits intrinsicBuilt(views, intrinsics) when done.\n\n        Args:\n            cameraInit (Node): CameraInit node to build the intrinsics for\n            additionalViews: list of additional views to add to the CameraInit viewpoints\n            rebuild (bool): whether to rebuild already created intrinsics\n        \"\"\"\n        views = []\n        intrinsics = []\n\n        # Duplicate 'cameraInit' outside the graph.\n        #   => allows to compute intrinsics without modifying the node or the graph\n        # If cameraInit is None:\n        #   * create an uninitialized node\n        #   * wait for the result before actually creating new nodes in the graph (see onIntrinsicsAvailable)\n        inputs = cameraInit.toDict()[\"inputs\"] if cameraInit else {}\n        cameraInitCopy = Node(\"CameraInit\", **inputs)\n        if rebuild:\n            # if rebuilding all intrinsics, for each Viewpoint:\n            for vp in cameraInitCopy.viewpoints.value:\n                vp.intrinsicId.resetToDefaultValue()  # reset intrinsic assignation\n                vp.metadata.resetToDefaultValue()  # and metadata (to clear any previous 'SensorWidth' entries)\n            # reset existing intrinsics list\n            cameraInitCopy.intrinsics.resetToDefaultValue()\n\n        try:\n            self.setBuildingIntrinsics(True)\n            # Retrieve the list of updated viewpoints and intrinsics\n            views, intrinsics = cameraInitCopy.nodeDesc.buildIntrinsics(cameraInitCopy, additionalViews)\n        except Exception as exc:\n            logging.error(f\"Error while building intrinsics: {exc}\")\n            raise\n        finally:\n            # Delete the duplicate\n            cameraInitCopy.deleteLater()\n            self.setBuildingIntrinsics(False)\n\n        # always emit intrinsicsBuilt signal to inform listeners\n        # in other threads that computation is over\n        self.intrinsicsBuilt.emit(cameraInit, views, intrinsics, rebuild)\n\n    @Slot(Node)\n    def rebuildIntrinsics(self, cameraInit):\n        \"\"\"\n        Rebuild intrinsics of 'cameraInit' from scratch.\n\n        Args:\n            cameraInit (Node): the CameraInit node\n        \"\"\"\n        self._workerThreads.apply_async(func=self.buildIntrinsics, args=(cameraInit, (), True,))\n\n    def onIntrinsicsAvailable(self, cameraInit, views, intrinsics, rebuild=False):\n        \"\"\" Update CameraInit with given views and intrinsics. \"\"\"\n        commandTitle = \"Add {} Images\"\n\n        if rebuild:\n            commandTitle = f\"Rebuild '{cameraInit.label}' Intrinsics\"\n\n        # No additional views: early return\n        if not views:\n            return\n\n        commandTitle = commandTitle.format(len(views))\n        # allow updates between commands so that node depths (useful for auto layout)\n        with self.groupedGraphModification(commandTitle, disableUpdates=False):\n            with self.groupedGraphModification(\"Set Views and Intrinsics\"):\n                self.setAttribute(cameraInit.viewpoints, views)\n                self.setAttribute(cameraInit.intrinsics, intrinsics)\n        self.cameraInit = cameraInit\n\n    def setBuildingIntrinsics(self, value):\n        if self._buildingIntrinsics == value:\n            return\n        self._buildingIntrinsics = value\n        self.buildingIntrinsicsChanged.emit()\n\n    activeNodes = makeProperty(QObject, \"_activeNodes\", resetOnDestroy=True)\n    cameraInitChanged = Signal()\n    cameraInit = makeProperty(QObject, \"_cameraInit\", cameraInitChanged, resetOnDestroy=True)\n    tempCameraInitChanged = Signal()\n    tempCameraInit = makeProperty(QObject, \"_tempCameraInit\", tempCameraInitChanged, resetOnDestroy=True)\n    cameraInitIndex = Property(int, getCameraInitIndex, setCameraInitIndex, notify=cameraInitChanged)\n    viewpoints = Property(QObject, getViewpoints, notify=cameraInitChanged)\n    cameraInits = Property(QObject, lambda self: self._cameraInits, constant=True)\n    importImagesFailed = Signal(str)\n    intrinsicsBuilt = Signal(QObject, list, list, bool)\n    buildingIntrinsicsChanged = Signal()\n    buildingIntrinsics = Property(bool, lambda self: self._buildingIntrinsics, notify=buildingIntrinsicsChanged)\n\n    displayedAttr2DChanged = Signal()\n    displayedAttr2D = makeProperty(QObject, \"_displayedAttr2D\", displayedAttr2DChanged)\n\n    displayedAttrs3DChanged = Signal()\n    displayedAttrs3D = Property(QObject, lambda self: self._displayedAttrs3D, notify=displayedAttrs3DChanged)\n\n    pluginsReloaded = Signal(list, list)\n\n    @Slot(QObject)\n    def setActiveNode(self, node, categories=True, inputs=True):\n        \"\"\" Set node as the active node of its type and of its categories.\n        Also upgrade related input nodes.\n        \"\"\"\n        if categories:\n            for category, nodeTypes in self.activeNodeCategories.items():\n                if node.nodeType in nodeTypes:\n                    self.activeNodes.getr(category).node = node\n\n                    if category == \"sfm\":\n                        self.setSfm(node)\n\n        if node.nodeType == \"CameraInit\":\n            # if the active node is a CameraInit node, update the camera init index\n            self.setCameraInitNode(node)\n        elif inputs:\n            # Update the input node to ensure that it is part of the dependency of the new active node.\n            # Retrieve all nodes that are input nodes of the new active node\n            inputNodes = node.getInputNodes(recursive=True, dependenciesOnly=True)\n            inputCameraInitNodes = [n for n in inputNodes if n.nodeType == \"CameraInit\"]\n            # if the current camera init node is not the same as the camera init node of the active node\n            if inputCameraInitNodes and self.cameraInit not in inputCameraInitNodes:\n                # set the camera init node of the active node as the current camera init node\n                # if multiple camera init, select one arbitrarily (the one with more viewpoints)\n                inputCameraInitNodes.sort(key=lambda n: len(n.viewpoints.value), reverse=True)\n                cameraInitNode = inputCameraInitNodes[0]\n                self.setCameraInitNode(cameraInitNode)\n\n        # Set the new active node (if it is not an unknown type)\n        unknownType = isinstance(node, CompatibilityNode) and node.issue == CompatibilityIssue.UnknownNodeType\n        if not unknownType:\n            activeNode = self.activeNodes.get(node.nodeType)\n            if activeNode:\n                activeNode.node = node\n\n    @Slot(QObject)\n    def setActiveNodes(self, nodes):\n        \"\"\" Set node as the active node of its type. \"\"\"\n        for node in nodes:\n            if node is None:\n                continue\n            self.setActiveNode(node, categories=False, inputs=False)\n\n    def resetActiveNodePerCategory(self):\n        # Setup the active node per category only once, on the last one\n        nodesByCategory = {}\n        for category, nodeTypes in self.activeNodeCategories.items():\n            node = self.lastNodeOfType(nodeTypes, self._cameraInit, Status.SUCCESS)\n            self.activeNodes.get(category).node = node\n\n    def updateSfMResults(self):\n        \"\"\"\n        Update internal views, poses and solved intrinsics based on the current SfM node.\n        \"\"\"\n        if not self._sfm or ('outputViewsAndPoses' not in self._sfm.getAttributes().keys()):\n            self._views = dict()\n            self._poses = dict()\n            self._solvedIntrinsics = dict()\n        else:\n            self._views, self._poses, self._solvedIntrinsics = parseSfMJsonFile(self._sfm.outputViewsAndPoses.value)\n        self.sfmReportChanged.emit()\n\n    def getSfm(self):\n        \"\"\" Returns the current SfM node. \"\"\"\n        return self._sfm\n\n    def _unsetSfm(self):\n        \"\"\" Unset current SfM node. This is shortcut equivalent to _setSfm(None). \"\"\"\n        self._setSfm(None)\n\n    def _setSfm(self, node):\n        \"\"\" Set current SfM node to 'node' and update views and poses.\n        Notes: this should not be called directly, use setSfm instead.\n        See Also: setSfm\n        \"\"\"\n        self._sfm = node\n        # Update sfm results and do so each time\n        # the status of the SfM node's only chunk changes\n        self.updateSfMResults()\n        if self._sfm:\n            # when destroyed, directly use '_setSfm' to bypass\n            # disconnection step in 'setSfm' (at this point, 'self._sfm' underlying object\n            # has been destroyed and cannot be evaluated anymore)\n            self._sfm.destroyed.connect(self._unsetSfm)\n            if len(self._sfm._chunks) > 0:\n                self._sfm.chunks[0].statusChanged.connect(self.updateSfMResults)\n        self.sfmChanged.emit()\n\n    def setSfm(self, node):\n        \"\"\" Set the current SfM node.\n        This node will be used to retrieve sparse 3D reconstruction result like camera poses.\n        \"\"\"\n        # disconnect from previous SfM node if any\n        if self._sfm:\n            self._sfm.chunks[0].statusChanged.disconnect(self.updateSfMResults)\n            self._sfm.destroyed.disconnect(self._unsetSfm)\n        self._setSfm(node)\n\n    @Slot(QObject, result=bool)\n    def isInViews(self, viewpoint):\n        if not viewpoint:\n            return False\n        # keys are strings (faster lookup)\n        return str(viewpoint.viewId.value) in self._views\n\n    @Slot(QObject, result=bool)\n    def isReconstructed(self, viewpoint):\n        if not viewpoint:\n            return False\n        # fetch up-to-date poseId from sfm result (in case of rigs, poseId might have changed)\n        if not self._views:\n            return False\n        view = self._views.get(str(viewpoint.poseId.value), None)  # keys are strings (faster lookup)\n        return view.get('poseId', -1) in self._poses if view else False\n\n    @Slot(QObject, result=bool)\n    def hasValidIntrinsic(self, viewpoint):\n        # keys are strings (faster lookup)\n        allIntrinsicIds = [i.intrinsicId.value for i in self._cameraInit.intrinsics.value]\n        return viewpoint.intrinsicId.value in allIntrinsicIds\n\n    @Slot(QObject, result=QObject)\n    def getIntrinsic(self, viewpoint):\n        \"\"\"\n        Get the intrinsic attribute associated to 'viewpoint' based on its intrinsicId.\n\n        Args:\n            viewpoint (Attribute): the Viewpoint to consider.\n        Returns:\n            Attribute: the Viewpoint's corresponding intrinsic or None if not found.\n        \"\"\"\n        if not viewpoint:\n            return None\n        return next((i for i in self._cameraInit.intrinsics.value if i.intrinsicId.value == viewpoint.intrinsicId.value)\n                    , None)\n\n    @Slot(QObject, result=bool)\n    def hasMetadata(self, viewpoint):\n        # Should be greater than 2 to avoid the particular case of \"\"\n        return len(viewpoint.metadata.value) > 2\n\n    def setSelectedViewId(self, viewId):\n        if viewId == self._selectedViewId:\n            return\n        self._selectedViewId = viewId\n        self.setPickedViewId(viewId)\n        vp = None\n        if self.viewpoints:\n            vp = next((v for v in self.viewpoints if str(v.viewId.value) == self._selectedViewId), None)\n        self._setSelectedViewpoint(vp)\n        self.selectedViewIdChanged.emit()\n\n    def _setSelectedViewpoint(self, viewpointAttribute):\n        if self._selectedViewpoint:\n            # Scene has ownership of Viewpoint object - destroy it when not needed anymore\n            self._selectedViewpoint.deleteLater()\n        self._selectedViewpoint = ViewpointWrapper(viewpointAttribute, self) if viewpointAttribute else None\n        self.selectedViewpointChanged.emit()\n\n    def setPickedViewId(self, viewId):\n        if viewId == self._pickedViewId:\n            return\n        self._pickedViewId = viewId\n        self.pickedViewIdChanged.emit()\n\n    @Slot(str)\n    def updateSelectedViewpoint(self, viewId):\n        \"\"\" Update the currently set viewpoint if the provided view ID corresponds to one. \"\"\"\n        vp = None\n        if self.viewpoints:\n            vp = next((v for v in self.viewpoints if str(v.viewId.value) == viewId), None)\n        self._setSelectedViewpoint(vp)\n\n    def reconstructedCamerasCount(self):\n        \"\"\" Get the number of reconstructed cameras in the current context. \"\"\"\n        viewpoints = self.getViewpoints()\n        # Check that the object is iterable to avoid error with undefined Qt Property\n        if not isinstance(viewpoints, Iterable):\n            return 0\n        return len([v for v in viewpoints if self.isReconstructed(v)])\n\n    @Slot(QObject, result=\"QVariant\")\n    def getSolvedIntrinsics(self, viewpoint):\n        \"\"\" Return viewpoint's solved intrinsics if it has been reconstructed, None otherwise.\n\n        Args:\n            viewpoint: the viewpoint object to instrinsics for.\n        \"\"\"\n        if not viewpoint:\n            return None\n        return self._solvedIntrinsics.get(str(viewpoint.intrinsicId.value), None)\n\n    def getPoseRT(self, viewpoint):\n        \"\"\" Get the camera pose as rotation and translation of the given viewpoint.\n\n        Args:\n            viewpoint: the viewpoint attribute to consider.\n        Returns:\n            R, T: the rotation and translation as lists of floats\n        \"\"\"\n        if not viewpoint:\n            return None, None\n        view = self._views.get(str(viewpoint.viewId.value), None)\n        if not view:\n            return None, None\n        pose = self._poses.get(view.get('poseId', -1), None)\n        if not pose:\n            return None, None\n\n        pose = pose[\"transform\"]\n        R = [float(i) for i in pose[\"rotation\"]]\n        T = [float(i) for i in pose[\"center\"]]\n\n        return R, T\n\n    def setCurrentViewPath(self, path):\n        if self._currentViewPath == path:\n            return\n        self._currentViewPath = path\n        self.currentViewPathChanged.emit()\n\n    @Slot(str, result=\"QVariantList\")\n    def evaluateMathExpression(self, expr):\n        \"\"\" Evaluate a mathematical expression and return the result as a string\n        Returns a list of 2 values :\n        - the result value\n        - a boolean that indicates whether an error occurred\n        \"\"\"\n        mev = MathEvaluator()\n        try:\n            res = mev.evaluate(expr)\n            return [res, False]\n        except Exception as err:\n            self.parent().showMessage(f\"Invalid field expression: {expr}\", \"error\")\n            return [None, True]\n\n    selectedViewIdChanged = Signal()\n    selectedViewId = Property(str, lambda self: self._selectedViewId, setSelectedViewId, notify=selectedViewIdChanged)\n    selectedViewpointChanged = Signal()\n    selectedViewpoint = Property(ViewpointWrapper, lambda self: self._selectedViewpoint, notify=selectedViewpointChanged)\n    pickedViewIdChanged = Signal()\n    pickedViewId = Property(str, lambda self: self._pickedViewId, setPickedViewId, notify=pickedViewIdChanged)\n\n    sfmChanged = Signal()\n    sfm = Property(QObject, getSfm, setSfm, notify=sfmChanged)\n\n    sfmReportChanged = Signal()\n    # convenient property for QML binding re-evaluation when sfm report changes\n    sfmReport = Property(bool, lambda self: len(self._poses) > 0, notify=sfmReportChanged)\n\n    nbCameras = Property(int, reconstructedCamerasCount, notify=sfmReportChanged)\n\n    # Provides the path of the image that is currently displayed\n    # This is an alternative to \"selectedViewpoint.attribute.path.value\" for images that are displayed\n    # but not part of the list of viewpoints of a CameraInit node (i.e. \"sequence\" node outputs)\n    currentViewPathChanged = Signal()\n    currentViewPath = Property(str, lambda self: self._currentViewPath, setCurrentViewPath, notify=currentViewPathChanged)\n\n    # Whether the Scene object has been set (\"new\" has been called) or not (\"new\" has never\n    # been called or \"clear\" has been called)\n    activeChanged = Signal()\n    active = Property(bool, lambda self: self._active, setActive, notify=activeChanged)\n\n    # Signals to propagate high-level messages\n    error = Signal(Message)\n    warning = Signal(Message)\n    info = Signal(Message)\n"
  },
  {
    "path": "meshroom/ui/utils.py",
    "content": "import os\nimport time\n\nfrom PySide6.QtCore import QFileSystemWatcher, QUrl, Slot, QTimer, Property, QObject\nfrom PySide6.QtQml import QQmlApplicationEngine\ntry:\n    from PySide6 import shiboken6\nexcept Exception:\n    import shiboken6\n\n\nclass QmlInstantEngine(QQmlApplicationEngine):\n    \"\"\"\n    QmlInstantEngine is a utility class helping to develop QML applications.\n    It reloads itself whenever one of the watched source files is modified.\n    As it consumes resources, make sure to disable file watching in production mode.\n    \"\"\"\n\n    def __init__(self, sourceFile=\"\", watching=True, verbose=False, parent=None):\n        \"\"\"\n        watching -- Defines whether the watcher is active (default: True)\n        verbose -- if True, output log information (default: False)\n        \"\"\"\n        super().__init__(parent)\n\n        self._fileWatcher = QFileSystemWatcher()  # Internal Qt File Watcher\n        self._sourceFile = \"\"\n        self._watchedFiles = []  # Internal watched files list\n        self._verbose = verbose  # Verbose bool\n        self._watching = False  #\n        self._extensions = [\"qml\", \"js\"]  # File extensions that defines files to watch when adding a folder\n\n        self._rootItem = None\n\n        def onObjectCreated(root, url):\n            if not root:\n                return\n            # Restore root item geometry\n            if self._rootItem:\n                root.setGeometry(self._rootItem.geometry())\n                self._rootItem.deleteLater()\n            self._rootItem = root\n\n        self.objectCreated.connect(onObjectCreated)\n\n        # Update the watching status\n        self.setWatching(watching)\n\n        if sourceFile:\n            self.load(sourceFile)\n\n    def load(self, sourceFile):\n        self._sourceFile = sourceFile\n        super().load(sourceFile)\n\n    def setWatching(self, watchValue):\n        \"\"\"\n        Enable (True) or disable (False) the file watching.\n        Tip: file watching should be enable only when developing.\n        \"\"\"\n        if self._watching is watchValue:\n            return\n\n        self._watching = watchValue\n        # Enable the watcher\n        if self._watching:\n            # 1. Add internal list of files to the internal Qt File Watcher\n            self.addFiles(self._watchedFiles)\n            # 2. Connect 'filechanged' signal\n            self._fileWatcher.fileChanged.connect(self.onFileChanged)\n\n        # Disabling the watcher\n        else:\n            # 1. Remove all files in the internal Qt File Watcher\n            self._fileWatcher.removePaths(self._watchedFiles)\n            # 2. Disconnect 'filechanged' signal\n            self._fileWatcher.fileChanged.disconnect(self.onFileChanged)\n\n    @property\n    def watchedExtensions(self):\n        \"\"\" Returns the list of extensions used when using addFilesFromDirectory. \"\"\"\n        return self._extensions\n\n    @watchedExtensions.setter\n    def watchedExtensions(self, extensions):\n        \"\"\" Set the list of extensions to search for when using addFilesFromDirectory. \"\"\"\n        self._extensions = extensions\n\n    def setVerbose(self, verboseValue):\n        \"\"\" Activate (True) or deactivate (False) the verbose. \"\"\"\n        self._verbose = verboseValue\n\n    def addFile(self, filename):\n        \"\"\"\n        Add the given 'filename' to the watched files list.\n        'filename' can be an absolute or relative path (str and QUrl accepted)\n        \"\"\"\n        # Deal with QUrl type\n        # NOTE: happens when using the source() method on a QQuickView\n        if isinstance(filename, QUrl):\n            filename = filename.path()\n\n        # Make sure the file exists\n        if not os.path.isfile(filename):\n            raise ValueError(f\"addFile: file {filename} does not exist.\")\n\n        # Return if the file is already in our internal list\n        if filename in self._watchedFiles:\n            return\n\n        # Add this file to the internal files list\n        self._watchedFiles.append(filename)\n        # And, if watching is active, add it to the internal watcher as well\n        if self._watching:\n            if self._verbose:\n                print(\"instantcoding: addPath\", filename)\n            self._fileWatcher.addPath(filename)\n\n    def addFiles(self, filenames):\n        \"\"\"\n        Add the given 'filenames' to the watched files list.\n        filenames -- a list of absolute or relative paths (str and QUrl accepted)\n        \"\"\"\n        # Convert to list\n        if not isinstance(filenames, list):\n            filenames = [filenames]\n\n        for filename in filenames:\n            self.addFile(filename)\n\n    def addFilesFromDirectory(self, dirname, recursive=False):\n        \"\"\"\n        Add files from the given directory name 'dirname'.\n        dirname -- an absolute or a relative path\n        recursive -- if True, will search inside each subdirectories recursively.\n        \"\"\"\n        if not os.path.isdir(dirname):\n            raise RuntimeError(f\"addFilesFromDirectory : {dirname} is not a valid directory.\")\n\n        if recursive:\n            for dirpath, dirnames, filenames in os.walk(dirname):\n                for filename in filenames:\n                    # Removing the starting dot from extension\n                    if os.path.splitext(filename)[1][1:] in self._extensions:\n                        self.addFile(os.path.join(dirpath, filename))\n        else:\n            filenames = os.listdir(dirname)\n            filenames = [os.path.join(dirname, filename) for filename in filenames if\n                         os.path.splitext(filename)[1][1:] in self._extensions]\n            self.addFiles(filenames)\n\n    def removeFile(self, filename):\n        \"\"\"\n        Remove the given 'filename' from the watched file list.\n        Tip: make sure to use relative or absolute path according to how you add this file.\n        \"\"\"\n        if filename in self._watchedFiles:\n            self._watchedFiles.remove(filename)\n        if self._watching:\n            self._fileWatcher.removePath(filename)\n\n    def getRegisteredFiles(self):\n        \"\"\" Returns the list of watched files \"\"\"\n        return self._watchedFiles\n\n    @Slot(str)\n    def onFileChanged(self, filepath):\n        \"\"\" Handle changes in a watched file. \"\"\"\n        if filepath not in self._watchedFiles:\n            # could happen if a file has just been reloaded\n            # and has not been re-added yet to the watched files\n            return\n\n        if self._verbose:\n            print(\"Source file changed : \", filepath)\n        # Clear the QQuickEngine cache\n        self.clearComponentCache()\n        # Remove the modified file from the watched list\n        self.removeFile(filepath)\n        cptTry = 0\n\n        # Make sure file is available before doing anything\n        # NOTE: useful to handle editors (Qt Creator) that deletes the source file and\n        #       creates a new one when saving\n        while not os.path.exists(filepath) and cptTry < 10:\n            time.sleep(0.1)\n            cptTry += 1\n\n        self.reload()\n\n        # Finally, re-add the modified file to the watch system\n        # after a short cooldown to avoid multiple consecutive reloads\n        QTimer.singleShot(200, lambda: self.addFile(filepath))\n\n    def reload(self):\n        print(f\"Reloading {self._sourceFile}\")\n        self.load(self._sourceFile)\n\n\ndef makeProperty(T, attributeName, notify=None, resetOnDestroy=False):\n    \"\"\"\n    Shortcut function to create a Qt Property with generic getter and setter.\n\n    Getter returns the underlying attribute value.\n    Setter sets and emit notify signal only if the given value is different from the current one.\n\n    Args:\n        T (type): the type of the property\n        attributeName (str): the name of underlying instance attribute to get/set\n        notify (Signal): the notify signal; if None, property will be constant\n        resetOnDestroy (bool): Only applicable for QObject-type properties.\n                               Whether to reset property to None when current value gets destroyed.\n\n\n    Examples:\n        class Foo(QObject):\n            _bar = 10\n            barChanged = Signal()\n            # read/write\n            bar = makeProperty(int, \"_bar\", notify=barChanged)\n            # read only (constant)\n            bar = makeProperty(int, \"_bar\")\n\n    Returns:\n        Property: the created Property\n    \"\"\"\n    def setter(instance, value):\n        \"\"\" Generic setter. \"\"\"\n        currentValue = getattr(instance, attributeName)\n        if currentValue == value:\n            return\n\n        resetCallbackName = '__reset__' + attributeName\n        if resetOnDestroy and not hasattr(instance, resetCallbackName):\n            # store reset callback on instance, only way to keep a reference to this function\n            # that can be used for destroyed signal (dis)connection\n            setattr(instance, resetCallbackName, lambda self=instance, *args: setter(self, None))\n        resetCallback = getattr(instance, resetCallbackName, None)\n\n        if resetCallback and currentValue and shiboken6.isValid(currentValue):\n            currentValue.destroyed.disconnect(resetCallback)\n        setattr(instance, attributeName, value)\n        if resetCallback and value:\n            value.destroyed.connect(resetCallback)\n        getattr(instance, signalName(notify)).emit()\n\n    def getter(instance):\n        \"\"\" Generic getter. \"\"\"\n        return getattr(instance, attributeName)\n\n    def signalName(signalInstance):\n        \"\"\" Get signal name from instance. \"\"\"\n        # string representation contains trailing '()', remove it\n        return str(signalInstance)[:-2]\n\n    if resetOnDestroy and not issubclass(T, QObject):\n        raise RuntimeError(\"destroyCallback can only be used with QObject-type properties.\")\n    if notify:\n        return Property(T, getter, setter, notify=notify)\n    else:\n        return Property(T, getter, constant=True)\n"
  },
  {
    "path": "requirements.txt",
    "content": "# runtime\npsutil>=5.6.7\nPySide6==6.8.3\nmarkdown==2.6.11\nrequests==2.32.4\npyseq==0.9.0\n"
  },
  {
    "path": "setup.py",
    "content": "import platform\n\nimport os\nimport setuptools  # for bdist\nfrom cx_Freeze import setup, Executable\nimport meshroom\n\ncurrentDir = os.path.dirname(os.path.abspath(__file__))\n\n\nclass PlatformExecutable(Executable):\n    \"\"\"\n    Extend cx_Freeze.Executable to handle platform variations.\n    \"\"\"\n\n    Windows = \"Windows\"\n    Linux = \"Linux\"\n    Darwin = \"Darwin\"\n\n    exeExtensions = {\n        Windows: \".exe\",\n        Linux: \"\",\n        Darwin: \".app\"\n    }\n\n    def __init__(self, script, initScript=None, base=None, targetName=None, icons=None, shortcutName=None,\n                 shortcutDir=None, copyright=None, trademarks=None):\n\n        # despite supposed to be optional, targetName is actually required on some configurations\n        if not targetName:\n            targetName = os.path.splitext(os.path.basename(script))[0]\n        # add platform extension to targetName\n        targetName += PlatformExecutable.exeExtensions[platform.system()]\n        # get icon for platform if defined\n        icon = icons.get(platform.system(), None) if icons else None\n        if platform.system() in (self.Linux, self.Darwin):\n            initScript = os.path.join(currentDir, \"setupInitScriptUnix.py\")\n        elif platform.system() is self.Windows:\n            initScript = os.path.join(currentDir, \"setupInitScriptWindows.py\")\n        super(PlatformExecutable, self).__init__(script, initScript, base, targetName, icon, shortcutName,\n                                                 shortcutDir, copyright, trademarks)\n\n\nbuild_exe_options = {\n    # include dynamically loaded plugins\n    \"packages\": [\"meshroom.nodes\", \"meshroom.submitters\"],\n    \"includes\": [\n        \"idna.idnadata\",  # Dependency needed by SketchfabUpload node, but not detected by cx_Freeze\n        \"timeit\",\n        \"pickletools\",\n        \"modulefinder\",\n        \"cProfile\",\n        \"colorsys\",\n        \"xml.dom.minidom\",\n        \"http.cookies\",\n        \"filecmp\",\n        \"logging.handlers\",\n        \"cmath\",\n        \"numpy\"\n    ],\n    \"include_files\": [\"CHANGES.md\", \"COPYING.md\", \"LICENSE-MPL2.md\", \"README.md\", \"bin\"]\n}\nif os.path.isdir(os.path.join(currentDir, \"tractor\")):\n    build_exe_options[\"packages\"].append(\"tractor\")\nif os.path.isdir(os.path.join(currentDir, \"simpleFarm\")):\n    build_exe_options[\"packages\"].append(\"simpleFarm\")\n\nif platform.system() == PlatformExecutable.Linux:\n    # include required system libs\n    # from https://github.com/Ultimaker/cura-build/blob/master/packaging/setup_linux.py.in\n    build_exe_options.update({\n        \"bin_path_includes\": [\n            \"/lib\",\n            \"/lib64\",\n            \"/usr/lib\",\n            \"/usr/lib64\",\n        ],\n        \"bin_includes\": [\n            \"libssl3\",\n            \"libssl\",\n            \"libcrypto\",\n        ],\n        \"bin_excludes\": [\n            \"linux-vdso.so\",\n            \"libpthread.so\",\n            \"libdl.so\",\n            \"librt.so\",\n            \"libstdc++.so\",\n            \"libm.so\",\n            \"libgcc_s.so\",\n            \"libc.so\",\n            \"ld-linux-x86-64.so\",\n            \"libz.so\",\n            \"libgcc_s.so\",\n            \"libglib-2\",\n            \"librt.so\",\n            \"libcap.so\",\n            \"libGL.so\",\n            \"libglapi.so\",\n            \"libXext.so\",\n            \"libXdamage.so\",\n            \"libXfixes.so\",\n            \"libX11-xcb.so\",\n            \"libX11.so\",\n            \"libxcb-glx.so\",\n            \"libxcb-dri2.so\",\n            \"libxcb.so\",\n            \"libXxf86vm.so\",\n            \"libdrm.so\",\n            \"libexpat.so\",\n            \"libXau.so\",\n            \"libglib-2.0.so\",\n            \"libgssapi_krb5.so\",\n            \"libgthread-2.0.so\",\n            \"libk5crypto.so\",\n            \"libkeyutils.so\",\n            \"libkrb5.so\",\n            \"libkrb5support.so\",\n            \"libresolv.so\",\n            \"libutil.so\",\n            \"libXrender.so\",\n            \"libcom_err.so\",\n            \"libgssapi_krb5.so\",\n        ]\n    })\n\nexecutables = [\n    # GUI\n    PlatformExecutable(\n        \"meshroom/ui/__main__.py\",\n        targetName=\"Meshroom\",\n        icons={PlatformExecutable.Windows: \"meshroom/ui/img/meshroom.ico\"}\n    ),\n    # Command line\n    PlatformExecutable(\"bin/meshroom_batch\"),\n    PlatformExecutable(\"bin/meshroom_compute\"),\n    PlatformExecutable(\"bin/meshroom_newNodeType\"),\n    PlatformExecutable(\"bin/meshroom_statistics\"),\n    PlatformExecutable(\"bin/meshroom_status\"),\n    PlatformExecutable(\"bin/meshroom_submit\"),\n]\n\nsetup(\n    name=\"Meshroom\",\n    description=\"Meshroom\",\n    install_requires=[\"psutil\", \"PySide6\", \"markdown\"],\n    setup_requires=[\n        \"cx_Freeze\"\n    ],\n    version=meshroom.__version__,\n    options={\"build_exe\": build_exe_options},\n    executables=executables,\n)\n"
  },
  {
    "path": "setupInitScriptUnix.py",
    "content": "# ------------------------------------------------------------------------------\n# ConsoleSetLibPath.py\n#   Initialization script for cx_Freeze which manipulates the path so that the\n# directory in which the executable is found is searched for extensions but\n# no other directory is searched. The environment variable LD_LIBRARY_PATH is\n# manipulated first, however, to ensure that shared libraries found in the\n# target directory are found. This requires a restart of the executable because\n# the environment variable LD_LIBRARY_PATH is only checked at startup.\n# ------------------------------------------------------------------------------\n\nimport os\nimport sys\nimport zipimport\n\nFILE_NAME = sys.executable\nDIR_NAME = os.path.dirname(sys.executable)\n\npaths = os.environ.get(\"LD_LIBRARY_PATH\", \"\").split(os.pathsep)\n\nif DIR_NAME not in paths:\n    paths.insert(0, DIR_NAME)\n    paths.insert(0, os.path.join(DIR_NAME, \"lib\"))\n    paths.insert(0, os.path.join(DIR_NAME, \"aliceVision\", \"lib\"))\n    paths.insert(0, os.path.join(DIR_NAME, \"aliceVision\", \"lib64\"))\n    paths.insert(0, os.path.join(DIR_NAME, \"lib\", \"PySide6\", \"Qt\", \"qml\", \"QtQuick\", \"Dialogs\"))\n\n    os.environ[\"LD_LIBRARY_PATH\"] = os.pathsep.join(paths)\n    os.environ[\"PYTHONPATH\"] = os.path.join(DIR_NAME, \"aliceVision\", \"lib\", \"python\") + os.pathsep + os.path.join(DIR_NAME, \"aliceVision\", \"lib\", \"python3.11\", \"site-packages\")\n    os.execv(sys.executable, sys.argv)\n\nsys.frozen = True\nsys.path = sys.path[:6]\n\n\ndef run(*args):\n    m = __import__(\"__main__\")\n    importer = zipimport.zipimporter(DIR_NAME + \"/lib/library.zip\")\n    if len(args) == 0:\n        name, ext = os.path.splitext(os.path.basename(os.path.normcase(FILE_NAME)))\n        moduleName = \"%s__main__\" % name\n    else:\n        moduleName = args[0]\n    pythonPaths = os.getenv(\"PYTHONPATH\", \"\").split(os.pathsep)\n    for p in pythonPaths:\n        sys.path.append(p)\n    code = importer.get_code(moduleName)\n    exec(code, m.__dict__)\n"
  },
  {
    "path": "setupInitScriptWindows.py",
    "content": "import os\nimport subprocess\nimport sys\nimport zipimport\n\nFILE_NAME = sys.executable\nDIR_NAME = os.path.dirname(sys.executable)\n\npaths = os.environ.get(\"ALICEVISION_LIBPATH\", \"\").split(os.pathsep)\nif DIR_NAME not in paths:\n    paths.insert(0, DIR_NAME)\n    paths.insert(0, os.path.join(DIR_NAME, \"lib\"))\n    paths.insert(0, os.path.join(DIR_NAME, \"aliceVision\", \"lib\"))\n    paths.insert(0, os.path.join(DIR_NAME, \"aliceVision\", \"bin\"))\n\n    os.environ[\"ALICEVISION_LIBPATH\"] = os.pathsep.join(paths)\n    os.environ[\"PYTHONPATH\"] = os.path.join(DIR_NAME, \"aliceVision\", \"lib\", \"python\") + os.pathsep + os.path.join(DIR_NAME, \"aliceVision\", \"lib\", \"python3.11\", \"site-packages\")\n    sys.exit(subprocess.call([sys.executable] + sys.argv[1:]))\n\nsys.frozen = True\nsys.path = sys.path[:5]\n\ndef run(*args):\n    m = __import__(\"__main__\")\n    importer = zipimport.zipimporter(DIR_NAME + \"/lib/library.zip\")\n    if len(args) == 0:\n        name, ext = os.path.splitext(os.path.basename(os.path.normcase(FILE_NAME)))\n        moduleName = \"%s__main__\" % name\n    else:\n        moduleName = args[0]\n    pythonPaths = os.getenv(\"PYTHONPATH\", \"\").split(os.pathsep)\n    for p in pythonPaths:\n        sys.path.append(p)\n    code = importer.get_code(moduleName)\n    exec(code, m.__dict__)\n"
  },
  {
    "path": "start.bat",
    "content": "REM Windows\nREM Add the aliceVision and qtPlugins folders with the binaries to this directory\n\nset MESHROOM_INSTALL_DIR=%CD%\nset PYTHONPATH=%CD%\n\nREM # Development options\nREM set MESHROOM_OUTPUT_QML_WARNINGS=1\nREM set MESHROOM_INSTANT_CODING=1\nREM set QT_PLUGIN_PATH=C:\\dev\\meshroom\\install\nREM set QML2_IMPORT_PATH=C:\\dev\\meshroom\\install\\qml\nREM set ALICEVISION_ROOT=C:\\dev\\AliceVision\\install\nREM set ALICEVISION_LIBPATH=%ALICEVISION_ROOT%\\bin;C:\\dev\\vcpkg\\installed\\x64-windows\\bin\n\nREM PYTHONPATH=%ALICEVISION_ROOT%\\lib\\python;%PYTHONPATH%\n\npython meshroom\\ui\n"
  },
  {
    "path": "start.sh",
    "content": "#!/bin/bash\nexport MESHROOM_ROOT=\"$(dirname \"$(readlink -f \"${BASH_SOURCE[0]}\" )\" )\"\nexport PYTHONPATH=$MESHROOM_ROOT:$PYTHONPATH\n\n# using existing alicevision release\n#export LD_LIBRARY_PATH=/foo/Meshroom-2023.2.0/aliceVision/lib/\n#export PATH=$PATH:/foo/Meshroom-2023.2.0/aliceVision/bin/\n\n# using alicevision built source\n#export PATH=$PATH:/foo/build/Linux-x86_64/\n\npython3 \"$MESHROOM_ROOT/meshroom/ui\"\n"
  },
  {
    "path": "tests/__init__.py",
    "content": "import os\n\nfrom meshroom.core import loadAllNodes\nfrom meshroom.core import pluginManager\n\nplugins = loadAllNodes(os.path.join(os.path.dirname(__file__), \"nodes\"))\nfor plugin in plugins:\n    pluginManager.addPlugin(plugin)\n\nif os.getenv(\"MESHROOM_PIPELINE_TEMPLATES_PATH\", False):\n    os.environ[\"MESHROOM_PIPELINE_TEMPLATES_PATH\"] += os.pathsep + os.path.dirname(os.path.realpath(__file__))\nelse:\n    os.environ[\"MESHROOM_PIPELINE_TEMPLATES_PATH\"] = os.path.dirname(os.path.realpath(__file__))\n"
  },
  {
    "path": "tests/appendTextAndFiles.mg",
    "content": "{\n    \"header\": {\n        \"releaseVersion\": \"2025.1.0-develop\",\n        \"fileVersion\": \"2.0\",\n        \"nodesVersions\": {},\n        \"template\": true\n    },\n    \"graph\": {\n        \"AppendFiles_1\": {\n            \"nodeType\": \"AppendFiles\",\n            \"position\": [\n                189,\n                8\n            ],\n            \"inputs\": {\n                \"input\": \"{AppendText_1.output}\",\n                \"input2\": \"{AppendText_2.output}\",\n                \"input3\": \"{AppendText_1.input}\",\n                \"input4\": \"{AppendText_2.input}\"\n            }\n        },\n        \"AppendText_1\": {\n            \"nodeType\": \"AppendText\",\n            \"position\": [\n                0,\n                0\n            ],\n            \"inputs\": {\n                \"inputText\": \"Input text from AppendText_1\"\n            }\n        },\n        \"AppendText_2\": {\n            \"nodeType\": \"AppendText\",\n            \"position\": [\n                0,\n                160\n            ],\n            \"inputs\": {\n                \"inputText\": \"Input text from AppendText_2\"\n            }\n        }\n    }\n}"
  },
  {
    "path": "tests/conftest.py",
    "content": "import tempfile\n\nimport pytest\n\nfrom meshroom.core.graph import Graph\n\n\n@pytest.fixture\ndef graphSavedOnDisk():\n    \"\"\"\n    Yield a Graph instance saved in a unique temporary folder.\n\n    Can be used for testing graph IO and computation in isolation.\n    \"\"\"\n    with tempfile.TemporaryDirectory() as cacheDir:\n        graph = Graph()\n        graph.saveAsTemp(cacheDir)\n        yield graph\n"
  },
  {
    "path": "tests/nodes/__init__.py",
    "content": ""
  },
  {
    "path": "tests/nodes/test/Color.py",
    "content": "from meshroom.core import desc\n\n\nclass Color(desc.Node):\n    inputs = [\n        desc.GroupAttribute(\n            name=\"rgb\",\n            label=\"rgb\",\n            description=\"rgb\",\n            exposed=True,\n            items=[\n                desc.FloatParam(name=\"r\", label=\"r\", description=\"r\", value=0.0),\n                desc.FloatParam(name=\"g\", label=\"g\", description=\"g\", value=0.0),\n                desc.FloatParam(name=\"b\", label=\"b\", description=\"b\", value=0.0),\n            ],\n        ),\n    ]\n\nclass NestedColor(desc.Node):\n    inputs = [\n        desc.GroupAttribute(\n            name=\"rgb\",\n            label=\"rgb\",\n            description=\"rgb\",\n            exposed=True,\n            items=[\n                desc.FloatParam(name=\"r\", label=\"r\", description=\"r\", value=0.0),\n                desc.FloatParam(name=\"g\", label=\"g\", description=\"g\", value=0.0),\n                desc.FloatParam(name=\"b\", label=\"b\", description=\"b\", value=0.0),\n                desc.GroupAttribute(label=\"test\", name=\"test\", description=\"\",\n                    items=[\n                        desc.FloatParam(name=\"r\", label=\"r\", description=\"r\", value=0.0),\n                        desc.FloatParam(name=\"g\", label=\"g\", description=\"g\", value=0.0),\n                        desc.FloatParam(name=\"b\", label=\"b\", description=\"b\", value=0.0),\n                    ],\n                ),\n            ],\n        ),\n    ]\n"
  },
  {
    "path": "tests/nodes/test/GroupAttributes.py",
    "content": "from meshroom.core import desc\n\n\nclass GroupAttributes(desc.Node):\n    documentation = \"\"\" Test node to connect GroupAttributes to other GroupAttributes. \"\"\"\n\n    # Inputs to the node\n    inputs = [\n        desc.GroupAttribute(\n            name=\"firstGroup\",\n            label=\"First Group\",\n            description=\"Group at the root level.\",\n            commandLineGroup=None,\n            exposed=True,\n            items=[\n                desc.IntParam(\n                    name=\"firstGroupIntA\",\n                    label=\"Integer A\",\n                    description=\"First integer in group.\",\n                    value=1024,\n                    range=(-1, 2000, 10),\n                    exposed=True,\n                ),\n                desc.BoolParam(\n                    name=\"firstGroupBool\",\n                    label=\"Boolean\",\n                    description=\"Boolean in group.\",\n                    value=True,\n                    advanced=True,\n                    exposed=True,\n                ),\n                desc.ChoiceParam(\n                    name=\"firstGroupExclusiveChoiceParam\",\n                    label=\"Exclusive Choice Param\",\n                    description=\"Exclusive choice parameter.\",\n                    value=\"one\",\n                    values=[\"one\", \"two\", \"three\", \"four\"],\n                    exclusive=True,\n                    exposed=True,\n                ),\n                desc.ChoiceParam(\n                    name=\"firstGroupChoiceParam\",\n                    label=\"ChoiceParam\",\n                    description=\"Non-exclusive choice parameter.\",\n                    value=[\"one\", \"two\"],\n                    values=[\"one\", \"two\", \"three\", \"four\"],\n                    exclusive=False,\n                    exposed=True\n                ),\n                desc.GroupAttribute(\n                    name=\"nestedGroup\",\n                    label=\"Nested Group\",\n                    description=\"A group within a group.\",\n                    commandLineGroup=None,\n                    exposed=True,\n                    items=[\n                        desc.FloatParam(\n                            name=\"nestedGroupFloat\",\n                            label=\"Floating Number\",\n                            description=\"Floating number in group.\",\n                            value=1.0,\n                            range=(0.0, 100.0, 0.01),\n                            exposed=True\n                        ),\n                    ],\n                ),\n                desc.ListAttribute(\n                    name=\"groupedList\",\n                    label=\"Grouped List\",\n                    description=\"List of groups within a group.\",\n                    advanced=True,\n                    exposed=True,\n                    elementDesc=desc.GroupAttribute(\n                        name=\"listedGroup\",\n                        label=\"Listed Group\",\n                        description=\"Group in a list within a group.\",\n                        joinChar=\":\",\n                        commandLineGroup=None,\n                        items=[\n                            desc.IntParam(\n                                name=\"listedGroupInt\",\n                                label=\"Integer 1\",\n                                description=\"Integer in a group in a list within a group.\",\n                                value=12,\n                                range=(3, 24, 1),\n                                exposed=True,\n                            ),\n                        ],\n                    ),\n                ),\n                desc.ListAttribute(\n                    name=\"singleGroupedList\",\n                    label=\"Grouped List With Single Element\",\n                    description=\"List of integers within a group.\",\n                    advanced=True,\n                    exposed=True,\n                    elementDesc=desc.IntParam(\n                        name=\"listedInt\",\n                        label=\"Integer In List\",\n                        description=\"Integer in a list within a group.\",\n                        value=40,\n                    ),\n                ),\n            ],\n        ),\n        desc.IntParam(\n            name=\"exposedInt\",\n            label=\"Exposed Integer\",\n            description=\"Integer at the root level, exposed.\",\n            value=1000,\n            exposed=True,\n        ),\n        desc.BoolParam(\n            name=\"unexposedBool\",\n            label=\"Unexposed Boolean\",\n            description=\"Boolean at the root level, unexposed.\",\n            value=True,\n        ),\n        desc.GroupAttribute(\n            name=\"inputGroup\",\n            label=\"Input Group\",\n            description=\"A group set as an input.\",\n            commandLineGroup=None,\n            items=[\n                desc.BoolParam(\n                    name=\"inputBool\",\n                    label=\"Input Bool\",\n                    description=\"\",\n                    value=False,\n                ),\n            ],\n        ),\n    ]\n\n    outputs = [\n        desc.GroupAttribute(\n            name=\"outputGroup\",\n            label=\"Output Group\",\n            description=\"A group set as an output.\",\n            commandLineGroup=None,\n            exposed=True,\n            items=[\n                desc.BoolParam(\n                    name=\"outputBool\",\n                    label=\"Output Bool\",\n                    description=\"\",\n                    value=False,\n                    exposed=True,\n                ),\n            ],\n        ),\n    ]\n"
  },
  {
    "path": "tests/nodes/test/InputDynamicOutputs.py",
    "content": "from meshroom.core import desc\n\nclass InputDynamicOutputs(desc.InputNode):\n    inputs = [\n        desc.File(\n            name=\"fileInput\",\n            label=\"File Input\",\n            description=\"A file input.\",\n            value=\"testFile\",\n        ),\n    ]\n\n    outputs = [\n        desc.File(\n            name=\"fileOutput\",\n            label=\"File Output\",\n            description=\"A file Output.\",\n            value=None,\n        ),\n    ]"
  },
  {
    "path": "tests/nodes/test/NestedTest.py",
    "content": "from meshroom.core import desc\n\n\nclass NestedTest(desc.Node):\n    inputs = [\n        desc.GroupAttribute(\n            name=\"xyz\",\n            label=\"xyz\",\n            description=\"xyz\",\n            exposed=True,\n            items=[\n                desc.FloatParam(name=\"x\", label=\"x\", description=\"x\", value=0.0),\n                desc.FloatParam(name=\"y\", label=\"z\", description=\"z\", value=0.0),\n                desc.FloatParam(name=\"z\", label=\"z\", description=\"z\", value=0.0),\n                desc.GroupAttribute(label=\"test\", name=\"test\", description=\"\",\n                    items=[\n                        desc.StringParam(name=\"x\", label=\"x\", description=\"x\", value=\"test\"),\n                        desc.FloatParam(name=\"y\", label=\"z\", description=\"z\", value=0.0),\n                        desc.FloatParam(name=\"z\", label=\"z\", description=\"z\", value=0.0),\n                    ],\n                ),\n            ],\n        ),\n    ]\n"
  },
  {
    "path": "tests/nodes/test/Position.py",
    "content": "from meshroom.core import desc\n\n\nclass Position(desc.Node):\n    inputs = [\n        desc.GroupAttribute(\n            name=\"xyz\",\n            label=\"xyz\",\n            description=\"xyz\",\n            exposed=True,\n            items=[\n                desc.FloatParam(name=\"x\", label=\"x\", description=\"x\", value=0.0),\n                desc.FloatParam(name=\"y\", label=\"y\", description=\"y\", value=0.0),\n                desc.FloatParam(name=\"z\", label=\"z\", description=\"z\", value=0.0),\n            ],\n        ),\n    ]\n\nclass NestedPosition(desc.Node):\n    inputs = [\n        desc.GroupAttribute(\n            name=\"xyz\",\n            label=\"xyz\",\n            description=\"xyz\",\n            exposed=True,\n            items=[\n                desc.FloatParam(name=\"x\", label=\"x\", description=\"x\", value=0.0),\n                desc.FloatParam(name=\"y\", label=\"y\", description=\"y\", value=0.0),\n                desc.FloatParam(name=\"z\", label=\"z\", description=\"z\", value=0.0),\n                desc.GroupAttribute(label=\"test\", name=\"test\", description=\"\",\n                    items=[\n                        desc.FloatParam(name=\"x\", label=\"x\", description=\"x\", value=0.0),\n                        desc.FloatParam(name=\"y\", label=\"y\", description=\"y\", value=0.0),\n                        desc.FloatParam(name=\"z\", label=\"z\", description=\"z\", value=0.0),\n                    ],\n                ),\n            ],\n        ),\n    ]\n"
  },
  {
    "path": "tests/nodes/test/__init__.py",
    "content": ""
  },
  {
    "path": "tests/nodes/test/appendFiles.py",
    "content": "from meshroom.core import desc\n\n\nclass AppendFiles(desc.CommandLineNode):\n    commandLine = 'cat {inputValue} {input2Value} {input3Value} {input4Value} > {outputValue}'\n\n    inputs = [\n        desc.File(\n            name='input',\n            label='Input File',\n            description='''''',\n            value='',\n        ),\n        desc.File(\n            name='input2',\n            label='Input File 2',\n            description='''''',\n            value='',\n        ),\n        desc.File(\n            name='input3',\n            label='Input File 3',\n            description='''''',\n            value='',\n        ),\n        desc.File(\n            name='input4',\n            label='Input File 4',\n            description='''''',\n            value='',\n        ),\n    ]\n\n    outputs = [\n        desc.File(\n            name='output',\n            label='Output',\n            description='''''',\n            value='{nodeCacheFolder}/appendText.txt',\n        )\n    ]\n"
  },
  {
    "path": "tests/nodes/test/appendText.py",
    "content": "from meshroom.core import desc\n\n\nclass AppendText(desc.CommandLineNode):\n    commandLine = 'cat {inputValue} > {outputValue} && echo {inputTextValue} >> {outputValue}'\n\n    inputs = [\n        desc.File(\n            name='input',\n            label='Input File',\n            description='''''',\n            value='',\n        ),\n        desc.File(\n            name='inputText',\n            label='Input Text',\n            description='''''',\n            value='',\n        )\n    ]\n\n    outputs = [\n        desc.File(\n            name='output',\n            label='Output',\n            description='''''',\n            value='{nodeCacheFolder}/appendText.txt',\n        ),\n    ]\n"
  },
  {
    "path": "tests/nodes/test/ls.py",
    "content": "from meshroom.core import desc\n\n\nclass Ls(desc.CommandLineNode):\n    commandLine = \"ls {inputValue} > {outputValue}\"\n    inputs = [\n        desc.File(\n            name=\"input\",\n            label=\"Input\",\n            description=\"\",\n            value=\"\",\n        )\n    ]\n\n    outputs = [\n        desc.File(\n            name=\"output\",\n            label=\"Output\",\n            description=\"\",\n            value=\"{nodeCacheFolder}/ls.txt\",\n        )\n    ]\n"
  },
  {
    "path": "tests/plugins/meshroom/pluginA/PluginAInputInitNode.py",
    "content": "__version__ = \"1.0\"\n\nfrom meshroom.core import desc\n\n\nclass PluginAInputInitNode(desc.InputNode, desc.InitNode):\n    inputs = [\n        desc.File(\n            name=\"input\",\n            label=\"Input\",\n            description=\"\",\n            value=\"\",\n        ),\n    ]\n\n    outputs = [\n        desc.File(\n            name=\"output\",\n            label=\"Output\",\n            description=\"\",\n            value=\"\",\n        ),\n    ]\n\n    def initialize(self, node, inputs, recursiveInputs):\n        if len(inputs) >= 1:\n            self.setAttributes(node, {\"input\": inputs[0]})\n"
  },
  {
    "path": "tests/plugins/meshroom/pluginA/PluginAInputNode.py",
    "content": "__version__ = \"1.0\"\n\nfrom meshroom.core import desc\n\n\nclass PluginAInputNode(desc.InputNode):\n    inputs = [\n        desc.File(\n            name=\"input\",\n            label=\"Input\",\n            description=\"\",\n            value=\"\",\n        ),\n    ]\n\n    outputs = [\n        desc.File(\n            name=\"output\",\n            label=\"Output\",\n            description=\"\",\n            value=\"\",\n        ),\n    ]\n"
  },
  {
    "path": "tests/plugins/meshroom/pluginA/PluginANodeA.py",
    "content": "__version__ = \"1.0\"\n\nimport time\n\nfrom meshroom.core import desc\n\n\nclass PluginANodeA(desc.Node):\n    inputs = [\n        desc.File(\n            name=\"input\",\n            label=\"Input\",\n            description=\"\",\n            value=\"\",\n        ),\n    ]\n\n    outputs = [\n        desc.File(\n            name=\"output\",\n            label=\"Output\",\n            description=\"\",\n            value=None,\n        ),\n    ]\n\n    def process(self, node):\n        time.sleep(3)  # Simulates a long process\n        node.output.value = node.input.value + \"_value\""
  },
  {
    "path": "tests/plugins/meshroom/pluginA/PluginANodeB.py",
    "content": "__version__ = \"1.0\"\n\nimport time\n\nfrom meshroom.core import desc\n\n\nclass PluginANodeB(desc.Node):\n    inputs = [\n        desc.File(\n            name=\"input\",\n            label=\"Input\",\n            description=\"\",\n            value=\"\",\n        ),\n        desc.IntParam(\n            name=\"int\",\n            label=\"Integer\",\n            description=\"\",\n            value=1,\n        ),\n    ]\n\n    outputs = [\n        desc.File(\n            name=\"output\",\n            label=\"Output\",\n            description=\"\",\n            value=\"\",\n        ),\n    ]\n\n    def process(self, node):\n        time.sleep(3)  # Simulates a long process\n"
  },
  {
    "path": "tests/plugins/meshroom/pluginA/__init__.py",
    "content": ""
  },
  {
    "path": "tests/plugins/meshroom/pluginB/PluginBNodeA.py",
    "content": "__version__ = \"1.0\"\n\nfrom meshroom.core import desc\n\n\nclass PluginBNodeA(desc.Node):\n    inputs = [\n        desc.File(\n            name=\"input\",\n            label=\"Input\",\n            description=\"\",\n            value=\"\",\n        ),\n    ]\n\n    outputs = [\n        desc.File(\n            name=\"output\",\n            label=\"Output\",\n            description=\"\",\n            value=\"\",\n        ),\n    ]\n"
  },
  {
    "path": "tests/plugins/meshroom/pluginB/PluginBNodeB.py",
    "content": "__version__ = \"1.0\"\n\nfrom meshroom.core import desc\n\n\nclass PluginBNodeB(desc.Node):\n    inputs = [\n        desc.File(\n            name=\"input\",\n            label=\"Input\",\n            description=\"\",\n            value=\"\",\n        ),\n        desc.IntParam(\n            name=\"int\",\n            label=\"Integer\",\n            description=\"\",\n            value=\"not an integer\",\n        ),\n    ]\n\n    outputs = [\n        desc.File(\n            name=\"output\",\n            label=\"Output\",\n            description=\"\",\n            value=\"\",\n        ),\n    ]\n"
  },
  {
    "path": "tests/plugins/meshroom/pluginB/__init__.py",
    "content": ""
  },
  {
    "path": "tests/plugins/meshroom/pluginC/PluginCNodeA.py",
    "content": "__version__ = \"1.0\"\n__license__ = \"no-license\"\n\nfrom meshroom.core import desc\n\n\nclass PluginCNodeA(desc.Node):\n    \"\"\"PluginCNodeA\"\"\"\n\n    author = \"testAuthor\"\n\n    inputs = [\n        desc.File(\n            name=\"input\",\n            label=\"Input\",\n            description=\"\",\n            value=\"\",\n        ),\n    ]\n\n    outputs = [\n        desc.File(\n            name=\"output\",\n            label=\"Output\",\n            description=\"\",\n            value=\"\",\n        ),\n    ]\n"
  },
  {
    "path": "tests/plugins/meshroom/pluginC/__init__.py",
    "content": ""
  },
  {
    "path": "tests/plugins/meshroom/pluginSubmitter/PluginSubmitter.py",
    "content": "__version__ = \"1.0\"\n\n\nimport logging\nfrom meshroom.core import desc\n\n\nLOGGER = logging.getLogger(\"TestSubmit\")\n\n\nclass PluginSubmitterA(desc.BaseNode):\n    \"\"\"\n    Test process no parallelization\n    \"\"\"\n    parallelization = None\n    \n    inputs = [\n        desc.IntParam(\n            name=\"input\",\n            label=\"Input\",\n            description=\"input\",\n            value=1,\n        ),\n    ]\n    outputs = [\n        desc.IntParam(\n            name=\"output\",\n            label=\"Output\",\n            description=\"Output\",\n            value=None,\n        ),\n    ]\n\n    def processChunk(self, chunk):\n        iteration = chunk.range.iteration\n        nbBlocks = chunk.range.nbBlocks\n        LOGGER.info(f\"> Process chunk {iteration}/{nbBlocks}\")\n        LOGGER.info(f\"> Done\")\n\n\nclass PluginSubmitterB(PluginSubmitterA):\n    \"\"\"\n    Test process with parallelization adn static node size\n    \"\"\"\n    size = desc.StaticNodeSize(2)\n    parallelization = desc.Parallelization(blockSize=1)\n\n\nclass PluginSubmitterC(PluginSubmitterA):\n    \"\"\"\n    Test process with parallelization and dynamic node size\n    \"\"\"\n    size = desc.DynamicNodeSize(\"input\")\n    parallelization = desc.Parallelization(blockSize=1)\n"
  },
  {
    "path": "tests/plugins/meshroom/pluginSubmitter/__init__.py",
    "content": ""
  },
  {
    "path": "tests/plugins/meshroom/sharedTemplate.mg",
    "content": "{\n    \"header\": {\n        \"releaseVersion\": \"2025.1.0-develop\",\n        \"fileVersion\": \"2.0\",\n        \"nodesVersions\": {},\n        \"template\": true\n    },\n    \"graph\": {\n    }\n}"
  },
  {
    "path": "tests/test_attributeChoiceParam.py",
    "content": "from meshroom.core import desc\nfrom meshroom.core.graph import Graph, loadGraph\n\nfrom .utils import registerNodeDesc, unregisterNodeDesc\n\n\nclass NodeWithChoiceParams(desc.Node):\n    inputs = [\n        desc.ChoiceParam(\n            name=\"choice\",\n            label=\"Choice Default Serialization\",\n            description=\"A choice parameter with standard serialization\",\n            value=\"A\",\n            values=[\"A\", \"B\", \"C\"],\n            saveValuesOverride=False,\n            exclusive=True,\n            exposed=True,\n        ),\n        desc.ChoiceParam(\n            name=\"choiceMulti\",\n            label=\"Choice Default Serialization\",\n            description=\"A choice parameter with standard serialization\",\n            value=[\"A\"],\n            values=[\"A\", \"B\", \"C\"],\n            saveValuesOverride=False,\n            exclusive=False,\n            exposed=True,\n        ),\n    ]\n\n\nclass NodeWithChoiceParamsSavingValuesOverride(desc.Node):\n    inputs = [\n        desc.ChoiceParam(\n            name=\"choice\",\n            label=\"Choice Custom Serialization\",\n            description=\"A choice parameter with serialization of overriden values\",\n            value=\"A\",\n            values=[\"A\", \"B\", \"C\"],\n            saveValuesOverride=True,\n            exclusive=True,\n            exposed=True,\n        ),\n        desc.ChoiceParam(\n            name=\"choiceMulti\",\n            label=\"Choice Custom Serialization\",\n            description=\"A choice parameter with serialization of overriden values\",\n            value=[\"A\"],\n            values=[\"A\", \"B\", \"C\"],\n            saveValuesOverride=True,\n            exclusive=False,\n            exposed=True,\n        )\n    ]\n\n\nclass TestChoiceParam:\n\n    @classmethod\n    def setup_class(cls):\n        registerNodeDesc(NodeWithChoiceParams)\n\n    @classmethod\n    def teardown_class(cls):\n        unregisterNodeDesc(NodeWithChoiceParams)\n\n    def test_customValueIsSerialized(self, graphSavedOnDisk):\n        graph: Graph = graphSavedOnDisk\n\n        node = graph.addNewNode(NodeWithChoiceParams.__name__)\n        node.choice.value = \"CustomValue\"\n        graph.save()\n\n        loadedGraph = loadGraph(graph.filepath)\n        assert loadedGraph.node(node.name).choice.value == \"CustomValue\"\n\n    def test_customMultiValueIsSerialized(self, graphSavedOnDisk):\n        graph: Graph = graphSavedOnDisk\n\n        node = graph.addNewNode(NodeWithChoiceParams.__name__)\n        node.choiceMulti.value = [\"custom\", \"value\"]\n        graph.save()\n\n        loadedGraph = loadGraph(graph.filepath)\n        assert loadedGraph.node(node.name).choiceMulti.value == [\"custom\", \"value\"]\n\n    def test_overridenValuesAreNotSerialized(self, graphSavedOnDisk):\n        graph: Graph = graphSavedOnDisk\n        node = graph.addNewNode(NodeWithChoiceParams.__name__)\n        node.choice.values = [\"D\", \"E\", \"F\"]\n\n        graph.save()\n        loadedGraph = loadGraph(graph.filepath)\n\n        assert loadedGraph.node(node.name).choice.values == [\"A\", \"B\", \"C\"]\n\n    def test_connectionPropagatesOverridenValues(self):\n        graph = Graph(\"\")\n\n        nodeA = graph.addNewNode(NodeWithChoiceParams.__name__)\n        nodeB = graph.addNewNode(NodeWithChoiceParams.__name__)\n        nodeA.choice.values = [\"D\", \"E\", \"F\"]\n        nodeA.choice.connectTo(nodeB.choice)\n\n        assert nodeB.choice.values == [\"D\", \"E\", \"F\"]\n\n    def test_connectionsAreSerialized(self, graphSavedOnDisk):\n        graph: Graph = graphSavedOnDisk\n        nodeA = graph.addNewNode(NodeWithChoiceParams.__name__)\n        nodeB = graph.addNewNode(NodeWithChoiceParams.__name__)\n        nodeA.choice.connectTo(nodeB.choice)\n        nodeA.choiceMulti.connectTo(nodeB.choiceMulti)\n\n        graph.save()\n\n        loadedGraph = loadGraph(graph.filepath)\n        loadedNodeA = loadedGraph.node(nodeA.name)\n        loadedNodeB = loadedGraph.node(nodeB.name)\n        assert loadedNodeB.choice.inputLink == loadedNodeA.choice\n        assert loadedNodeB.choiceMulti.inputLink == loadedNodeA.choiceMulti\n\n\nclass TestChoiceParamSavingCustomValues:\n\n    @classmethod\n    def setup_class(cls):\n        registerNodeDesc(NodeWithChoiceParamsSavingValuesOverride)\n\n    @classmethod\n    def teardown_class(cls):\n        unregisterNodeDesc(NodeWithChoiceParamsSavingValuesOverride)\n\n    def test_customValueIsSerialized(self, graphSavedOnDisk):\n        graph: Graph = graphSavedOnDisk\n\n        node = graph.addNewNode(NodeWithChoiceParamsSavingValuesOverride.__name__)\n        node.choice.value = \"CustomValue\"\n        node.choiceMulti.value = [\"custom\", \"value\"]\n        graph.save()\n\n        loadedGraph = loadGraph(graph.filepath)\n        assert loadedGraph.node(node.name).choice.value == \"CustomValue\"\n        assert loadedGraph.node(node.name).choiceMulti.value == [\"custom\", \"value\"]\n\n    def test_overridenValuesAreSerialized(self, graphSavedOnDisk):\n        graph: Graph = graphSavedOnDisk\n        node = graph.addNewNode(NodeWithChoiceParamsSavingValuesOverride.__name__)\n        node.choice.values = [\"D\", \"E\", \"F\"]\n        node.choiceMulti.values = [\"D\", \"E\", \"F\"]\n\n        graph.save()\n        loadedGraph = loadGraph(graph.filepath)\n\n        loadedNode = loadedGraph.node(node.name)\n\n        assert loadedNode.choice.values == [\"D\", \"E\", \"F\"]\n        assert loadedNode.choiceMulti.values == [\"D\", \"E\", \"F\"]\n\n    def test_connectionsAreSerialized(self, graphSavedOnDisk):\n        graph: Graph = graphSavedOnDisk\n        nodeA = graph.addNewNode(NodeWithChoiceParamsSavingValuesOverride.__name__)\n        nodeB = graph.addNewNode(NodeWithChoiceParamsSavingValuesOverride.__name__)\n        nodeA.choice.connectTo(nodeB.choice)\n        nodeA.choiceMulti.connectTo(nodeB.choiceMulti)\n\n        graph.save()\n\n        loadedGraph = loadGraph(graph.filepath)\n        loadedNodeA = loadedGraph.node(nodeA.name)\n        loadedNodeB = loadedGraph.node(nodeB.name)\n        assert loadedNodeB.choice.inputLink == loadedNodeA.choice\n        assert loadedNodeB.choiceMulti.inputLink == loadedNodeA.choiceMulti\n"
  },
  {
    "path": "tests/test_attributeDescDefaults.py",
    "content": "\"\"\"\nTests for optional label/description/value arguments on attribute descriptors.\n\nCovers:\n- All param types can be created with minimal arguments (name only)\n- Param descriptors created without a value are marked as dynamic (isDynamicValue=True)\n- Output attributes with isDynamicValue=True have None as their runtime value\n- Input attributes without a value return the expected type default at runtime\n\"\"\"\nimport pytest\n\nfrom meshroom.core import desc\nfrom meshroom.core.graph import Graph\n\nfrom .utils import registerNodeDesc, unregisterNodeDesc\n\n\n# ---------------------------------------------------------------------------\n# A node whose inputs all use the descriptor default (value=None).\n# Each typed param will have its type's zero-value as the runtime default.\n# ---------------------------------------------------------------------------\nclass NodeWithMinimalInputs(desc.Node):\n    inputs = [\n        desc.File(name=\"fileInput\"),\n        desc.BoolParam(name=\"boolInput\"),\n        desc.IntParam(name=\"intInput\"),\n        desc.FloatParam(name=\"floatInput\"),\n        desc.StringParam(name=\"stringInput\"),\n        desc.ColorParam(name=\"colorInput\"),\n        desc.ChoiceParam(name=\"choiceInput\", values=[\"a\", \"b\", \"c\"]),\n    ]\n    outputs = []\n\n    def process(self, node):\n        pass\n\n\n# ---------------------------------------------------------------------------\n# A node whose outputs all use the descriptor default (value=None) so they\n# are treated as dynamic values computed at runtime.\n# ---------------------------------------------------------------------------\nclass NodeWithDynamicOutputsMinimal(desc.Node):\n    inputs = []\n    outputs = [\n        desc.File(name=\"fileOutput\"),\n        desc.BoolParam(name=\"boolOutput\"),\n        desc.IntParam(name=\"intOutput\"),\n        desc.FloatParam(name=\"floatOutput\"),\n        desc.StringParam(name=\"stringOutput\"),\n    ]\n\n    def process(self, node):\n        pass\n\n\n# ---------------------------------------------------------------------------\n# Tests on the descriptor objects themselves (no Graph required)\n# ---------------------------------------------------------------------------\n\n# Pairs of (descriptor, expected_label_from_name)\n_MINIMAL_DESCS = [\n    desc.File(name=\"outputFile\"),\n    desc.BoolParam(name=\"myBool\"),\n    desc.IntParam(name=\"intValue\"),\n    desc.FloatParam(name=\"floatValue\"),\n    desc.StringParam(name=\"stringParam\"),\n    desc.ColorParam(name=\"primaryColor\"),\n    desc.PushButtonParam(name=\"applyButton\"),\n    desc.ChoiceParam(name=\"modeChoice\"),\n    desc.ListAttribute(desc.StringParam(name=\"elem\"), name=\"itemList\"),\n    desc.GroupAttribute([], name=\"optionGroup\"),\n]\n\n\n@pytest.mark.parametrize(\"attrDesc\", _MINIMAL_DESCS, ids=lambda d: type(d).__name__)\ndef test_param_minimal_creation(attrDesc):\n    \"\"\"All attribute types should be constructible with minimal arguments (name only).\"\"\"\n    assert attrDesc.name is not None\n    assert attrDesc.label != \"\"       # label is auto-generated from the name\n    assert attrDesc.description == \"\" # description defaults to empty string\n\n\n@pytest.mark.parametrize(\"attrDesc\", [\n    desc.File(name=\"fileParam\"),\n    desc.BoolParam(name=\"boolParam\"),\n    desc.IntParam(name=\"intParam\"),\n    desc.FloatParam(name=\"floatParam\"),\n    desc.StringParam(name=\"stringParam\"),\n    desc.ColorParam(name=\"colorParam\"),\n    desc.PushButtonParam(name=\"pushButton\"),\n    desc.ChoiceParam(name=\"choiceParam\"),\n], ids=lambda d: type(d).__name__)\ndef test_param_no_value_is_dynamic(attrDesc):\n    \"\"\"Param descriptors created without a value should be marked as dynamic.\"\"\"\n    assert attrDesc.isDynamicValue is True\n\n\ndef test_list_and_group_attributes_not_dynamic():\n    \"\"\"ListAttribute and GroupAttribute always have a non-None default value.\"\"\"\n    la = desc.ListAttribute(desc.StringParam(name=\"elem\"), name=\"items\")\n    ga = desc.GroupAttribute([], name=\"group\")\n    assert la.isDynamicValue is False\n    assert ga.isDynamicValue is False\n\n\ndef test_label_auto_generated_from_camel_case():\n    \"\"\"Label should be auto-generated from camelCase attribute names.\"\"\"\n    assert desc.File(name=\"outputFile\").label == \"Output File\"\n    assert desc.IntParam(name=\"frameCount\").label == \"Frame Count\"\n    assert desc.BoolParam(name=\"useGPU\").label == \"Use GPU\"\n\n\ndef test_label_auto_generated_from_snake_case():\n    \"\"\"Label should be auto-generated from snake_case attribute names.\"\"\"\n    assert desc.StringParam(name=\"input_path\").label == \"Input Path\"\n    assert desc.FloatParam(name=\"min_value\").label == \"Min Value\"\n\n\ndef test_explicit_label_overrides_auto_generated():\n    \"\"\"An explicitly provided label should take precedence over the auto-generated one.\"\"\"\n    attr = desc.File(name=\"outputFile\", label=\"My Custom Label\")\n    assert attr.label == \"My Custom Label\"\n\n\ndef test_explicit_description_preserved():\n    \"\"\"An explicitly provided description should be stored as-is.\"\"\"\n    attr = desc.IntParam(name=\"count\", description=\"Number of items to process.\")\n    assert attr.description == \"Number of items to process.\"\n\n\n# ---------------------------------------------------------------------------\n# Tests on attribute runtime instances (require a Graph / node)\n# ---------------------------------------------------------------------------\n\nclass TestInputParamDefaults:\n    \"\"\"Input params with no explicit value should use the type's zero/empty default.\"\"\"\n\n    @classmethod\n    def setup_class(cls):\n        registerNodeDesc(NodeWithMinimalInputs)\n\n    @classmethod\n    def teardown_class(cls):\n        unregisterNodeDesc(NodeWithMinimalInputs)\n\n    @pytest.fixture\n    def node(self):\n        graph = Graph(\"\")\n        return graph.addNewNode(NodeWithMinimalInputs.__name__)\n\n    def test_file_input_default(self, node):\n        assert node.fileInput.value == \"\"\n\n    def test_bool_input_default(self, node):\n        assert node.boolInput.value is False\n\n    def test_int_input_default(self, node):\n        assert node.intInput.value == 0\n\n    def test_float_input_default(self, node):\n        assert node.floatInput.value == 0.0\n\n    def test_string_input_default(self, node):\n        assert node.stringInput.value == \"\"\n\n    def test_color_input_default(self, node):\n        assert node.colorInput.value == \"\"\n\n    def test_choice_input_default(self, node):\n        # ChoiceParam with string values → _valueType=str → str() = \"\"\n        assert node.choiceInput.value == \"\"\n\n\nclass TestOutputParamDynamicValue:\n    \"\"\"Output params created without a default value should be dynamic (None at runtime).\"\"\"\n\n    @classmethod\n    def setup_class(cls):\n        registerNodeDesc(NodeWithDynamicOutputsMinimal)\n\n    @classmethod\n    def teardown_class(cls):\n        unregisterNodeDesc(NodeWithDynamicOutputsMinimal)\n\n    @pytest.fixture\n    def node(self):\n        graph = Graph(\"\")\n        return graph.addNewNode(NodeWithDynamicOutputsMinimal.__name__)\n\n    def test_output_desc_is_dynamic(self, node):\n        assert node.fileOutput.desc.isDynamicValue is True\n        assert node.boolOutput.desc.isDynamicValue is True\n        assert node.intOutput.desc.isDynamicValue is True\n        assert node.floatOutput.desc.isDynamicValue is True\n        assert node.stringOutput.desc.isDynamicValue is True\n\n    def test_file_output_value_is_none(self, node):\n        assert node.fileOutput.value is None\n\n    def test_bool_output_value_is_none(self, node):\n        assert node.boolOutput.value is None\n\n    def test_int_output_value_is_none(self, node):\n        assert node.intOutput.value is None\n\n    def test_float_output_value_is_none(self, node):\n        assert node.floatOutput.value is None\n\n    def test_string_output_value_is_none(self, node):\n        assert node.stringOutput.value is None\n"
  },
  {
    "path": "tests/test_attributeKeyValues.py",
    "content": "from meshroom.core import desc\nfrom meshroom.core.graph import Graph\n\nfrom .utils import registerNodeDesc, unregisterNodeDesc\n\n\nclass NodeWithKeyableAttributes(desc.Node):\n    inputs = [\n        desc.BoolParam(\n            name=\"keyableBool\",\n            label=\"Keyable Bool\",\n            description=\"A keyable bool parameter.\",\n            value=True,\n            keyable=True,\n            keyType=\"viewId\"\n        ),\n        desc.IntParam(\n            name=\"keyableInt\",\n            label=\"Keyable Integer\",\n            description=\"A keyable integer parameter.\",\n            value=5,\n            range=(0, 100, 2),\n            keyable=True,\n            keyType=\"viewId\"\n        ),\n        desc.FloatParam(\n            name=\"keyableFloat\",\n            label=\"Keyable Float\",\n            description=\"A keyable float parameter.\",\n            value=5.5,\n            range=(0.0, 100.0, 2.2),\n            keyable=True,\n            keyType=\"viewId\"\n        ),\n    ]\n\nclass TestKeyableAttribute:\n\n    @classmethod\n    def setup_class(cls):\n        registerNodeDesc(NodeWithKeyableAttributes)\n\n    @classmethod\n    def teardown_class(cls):\n        unregisterNodeDesc(NodeWithKeyableAttributes)\n\n    def test_initialization(self):\n        graph = Graph(\"\")\n\n        nodeA = graph.addNewNode(NodeWithKeyableAttributes.__name__)\n\n        # Check attribute is keyable\n        assert nodeA.keyableBool.keyable\n        assert nodeA.keyableInt.keyable\n        assert nodeA.keyableFloat.keyable\n\n        # Check attribute key type\n        assert nodeA.keyableBool.keyValues.keyType == \"viewId\"\n        assert nodeA.keyableInt.keyValues.keyType == \"viewId\"\n        assert nodeA.keyableFloat.keyValues.keyType == \"viewId\"\n\n        # Check attribute pairs empty\n        assert nodeA.keyableBool.isDefault\n        assert nodeA.keyableInt.isDefault\n        assert nodeA.keyableFloat.isDefault\n\n        # Check attribute description value\n        assert nodeA.keyableBool.desc.value == True\n        assert nodeA.keyableInt.desc.value == 5\n        assert nodeA.keyableFloat.desc.value == 5.5\n\n        # Check attribute default value\n        assert nodeA.keyableBool.getDefaultValue() == {}\n        assert nodeA.keyableInt.getDefaultValue() == {}\n        assert nodeA.keyableFloat.getDefaultValue() == {}\n\n        # Check attribute serialized value\n        assert nodeA.keyableBool.getSerializedValue() == {}\n        assert nodeA.keyableInt.getSerializedValue() == {}\n        assert nodeA.keyableFloat.getSerializedValue() == {}\n\n        # Check attribute string value\n        assert nodeA.keyableBool.getValueStr() == \"{}\"\n        assert nodeA.keyableInt.getValueStr() == \"{}\"\n        assert nodeA.keyableFloat.getValueStr() == \"{}\"\n\n\n    def test_createReadUpdateDelete(self):\n        graph = Graph(\"\")\n\n        nodeA = graph.addNewNode(NodeWithKeyableAttributes.__name__)\n\n        # Check attribute value at key \"0\", should be default value\n        assert nodeA.keyableBool.keyValues.getValueAtKeyOrDefault(\"0\") == True\n        assert nodeA.keyableInt.keyValues.getValueAtKeyOrDefault(\"0\") == 5\n        assert nodeA.keyableFloat.keyValues.getValueAtKeyOrDefault(\"0\") == 5.5\n\n        # Check attribute has key \"0\", should be False (no key)\n        assert nodeA.keyableBool.keyValues.hasKey(\"0\") == False\n        assert nodeA.keyableInt.keyValues.hasKey(\"0\") == False\n        assert nodeA.keyableFloat.keyValues.hasKey(\"0\") == False\n\n        # Add attribute (key, value) at key \"0\"\n        nodeA.keyableBool.keyValues.add(\"0\", False)\n        nodeA.keyableInt.keyValues.add(\"0\", 10)\n        nodeA.keyableFloat.keyValues.add(\"0\", 10.1)\n\n        # Check attribute value at key \"0\", should be the new value\n        assert nodeA.keyableBool.keyValues.getValueAtKeyOrDefault(\"0\") == False\n        assert nodeA.keyableInt.keyValues.getValueAtKeyOrDefault(\"0\") == 10\n        assert nodeA.keyableFloat.keyValues.getValueAtKeyOrDefault(\"0\") == 10.1\n\n        # Check attribute has key \"0\", should be True (key exists)\n        assert nodeA.keyableBool.keyValues.hasKey(\"0\") == True\n        assert nodeA.keyableInt.keyValues.hasKey(\"0\") == True\n        assert nodeA.keyableFloat.keyValues.hasKey(\"0\") == True\n\n        # Update attribute (key, value) at key \"0\"\n        nodeA.keyableBool.keyValues.add(\"0\", True)\n        nodeA.keyableInt.keyValues.add(\"0\", 20)\n        nodeA.keyableFloat.keyValues.add(\"0\", 20.2)\n\n        # Check attribute value at key \"0\", should be the new updated value\n        assert nodeA.keyableBool.keyValues.getValueAtKeyOrDefault(\"0\") == True\n        assert nodeA.keyableInt.keyValues.getValueAtKeyOrDefault(\"0\") == 20\n        assert nodeA.keyableFloat.keyValues.getValueAtKeyOrDefault(\"0\") == 20.2\n\n        # Check attribute has key \"0\", should be True (key exists)\n        assert nodeA.keyableBool.keyValues.hasKey(\"0\") == True\n        assert nodeA.keyableInt.keyValues.hasKey(\"0\") == True\n        assert nodeA.keyableFloat.keyValues.hasKey(\"0\") == True\n\n        # Remove (key, value) at key \"0\"\n        nodeA.keyableBool.keyValues.remove(\"0\")\n        nodeA.keyableInt.keyValues.remove(\"0\")\n        nodeA.keyableFloat.keyValues.remove(\"0\")\n\n        # Check attributes values at key \"0\", should be default value\n        assert nodeA.keyableBool.keyValues.getValueAtKeyOrDefault(\"0\") == True\n        assert nodeA.keyableInt.keyValues.getValueAtKeyOrDefault(\"0\") == 5\n        assert nodeA.keyableFloat.keyValues.getValueAtKeyOrDefault(\"0\") == 5.5\n\n        # Check attribute has key \"0\", should be False (no key)\n        assert nodeA.keyableBool.keyValues.hasKey(\"0\") == False\n        assert nodeA.keyableInt.keyValues.hasKey(\"0\") == False\n        assert nodeA.keyableFloat.keyValues.hasKey(\"0\") == False\n\n\n    def test_multipleKeys(self):\n        graph = Graph(\"\")\n\n        nodeA = graph.addNewNode(NodeWithKeyableAttributes.__name__)\n\n        # Add attribute (key, value) at key \"0\"\n        nodeA.keyableBool.keyValues.add(\"0\", False)\n        nodeA.keyableInt.keyValues.add(\"0\", 1)\n        nodeA.keyableFloat.keyValues.add(\"0\", 1.1)\n\n        # Add attribute (key, value) at key \"1\"\n        nodeA.keyableBool.keyValues.add(\"1\", False)\n        nodeA.keyableInt.keyValues.add(\"1\", 2)\n        nodeA.keyableFloat.keyValues.add(\"1\", 2.2)\n\n        # Add attribute (key, value) at key \"2\"\n        nodeA.keyableBool.keyValues.add(\"2\", True)\n        nodeA.keyableInt.keyValues.add(\"2\", 3)\n        nodeA.keyableFloat.keyValues.add(\"2\", 3.3)\n\n        # Check attribute has key \"0\", should be True (key exists)\n        assert nodeA.keyableBool.keyValues.hasKey(\"0\") == True\n        assert nodeA.keyableInt.keyValues.hasKey(\"0\") == True\n        assert nodeA.keyableFloat.keyValues.hasKey(\"0\") == True\n\n        # Check attribute has key \"1\", should be True (key exists)\n        assert nodeA.keyableBool.keyValues.hasKey(\"1\") == True\n        assert nodeA.keyableInt.keyValues.hasKey(\"1\") == True\n        assert nodeA.keyableFloat.keyValues.hasKey(\"1\") == True\n\n        # Check attribute has key \"2\", should be True (key exists)\n        assert nodeA.keyableBool.keyValues.hasKey(\"2\") == True\n        assert nodeA.keyableInt.keyValues.hasKey(\"2\") == True\n        assert nodeA.keyableFloat.keyValues.hasKey(\"2\") == True\n\n        # Check attributes values at key \"0\", should be default value\n        assert nodeA.keyableBool.keyValues.getValueAtKeyOrDefault(\"0\") == False\n        assert nodeA.keyableInt.keyValues.getValueAtKeyOrDefault(\"0\") == 1\n        assert nodeA.keyableFloat.keyValues.getValueAtKeyOrDefault(\"0\") == 1.1\n\n        # Check attributes values at key \"1\", should be default value\n        assert nodeA.keyableBool.keyValues.getValueAtKeyOrDefault(\"1\") == False\n        assert nodeA.keyableInt.keyValues.getValueAtKeyOrDefault(\"1\") == 2\n        assert nodeA.keyableFloat.keyValues.getValueAtKeyOrDefault(\"1\") == 2.2\n\n        # Check attributes values at key \"2\", should be default value\n        assert nodeA.keyableBool.keyValues.getValueAtKeyOrDefault(\"2\") == True\n        assert nodeA.keyableInt.keyValues.getValueAtKeyOrDefault(\"2\") == 3\n        assert nodeA.keyableFloat.keyValues.getValueAtKeyOrDefault(\"2\") == 3.3\n\n        # Remove (key, value) at key \"1\"\n        nodeA.keyableBool.keyValues.remove(\"1\")\n        nodeA.keyableInt.keyValues.remove(\"1\")\n        nodeA.keyableFloat.keyValues.remove(\"1\")\n\n        # Check attribute has key \"1\", should be False (no key)\n        assert nodeA.keyableBool.keyValues.hasKey(\"1\") == False\n        assert nodeA.keyableInt.keyValues.hasKey(\"1\") == False\n        assert nodeA.keyableFloat.keyValues.hasKey(\"1\") == False\n\n\n    def test_linkAttribute(self):\n        graph = Graph(\"\")\n\n        nodeA = graph.addNewNode(NodeWithKeyableAttributes.__name__)\n        nodeB = graph.addNewNode(NodeWithKeyableAttributes.__name__)\n\n        # Add some keys for nodeA.keyableInt\n        nodeA.keyableInt.keyValues.add(\"0\", 0)\n        nodeA.keyableInt.keyValues.add(\"1\", 1)\n        nodeA.keyableInt.keyValues.add(\"2\", 2)\n\n        # Add link:\n        # nodeB.keyableInt is a link for nodeA.keyableInt\n        nodeA.keyableInt.connectTo(nodeB.keyableInt)\n\n        # Check link\n        assert nodeB.keyableInt.isLink == True\n        assert nodeB.keyableInt.keyValues == nodeA.keyableInt.keyValues\n\n        # Check existing (key, value) in nodeA.keyableInt and nodeB.keyableInt\n        assert nodeA.keyableInt.keyValues.hasKey(\"1\") == True\n        assert nodeB.keyableInt.keyValues.hasKey(\"1\") == True\n        assert nodeA.keyableInt.keyValues.getValueAtKeyOrDefault(\"1\") == 1\n        assert nodeB.keyableInt.keyValues.getValueAtKeyOrDefault(\"1\") == 1\n\n        # Add a key to nodeB.keyableInt\n        nodeB.keyableInt.keyValues.add(\"3\", 3)\n\n        # Check new (key, value) in nodeA.keyableInt and nodeB.keyableInt\n        assert nodeA.keyableInt.keyValues.hasKey(\"3\") == True\n        assert nodeB.keyableInt.keyValues.hasKey(\"3\") == True\n        assert nodeA.keyableInt.keyValues.getValueAtKeyOrDefault(\"3\") == 3\n        assert nodeB.keyableInt.keyValues.getValueAtKeyOrDefault(\"3\") == 3\n\n        # Check nodeB.keyableInt serialized values\n        assert nodeB.keyableInt.getSerializedValue() == nodeA.keyableInt.asLinkExpr()\n\n\n    def test_uid(self):\n        graph = Graph(\"\")\n\n        nodeA = graph.addNewNode(NodeWithKeyableAttributes.__name__)\n        nodeB = graph.addNewNode(NodeWithKeyableAttributes.__name__)\n\n        # Add some keys for nodeA.keyableInt\n        nodeA.keyableInt.keyValues.add(\"0\", 0)\n        nodeA.keyableInt.keyValues.add(\"1\", 1)\n        nodeA.keyableInt.keyValues.add(\"2\", 2)\n\n        # Add the same keys for nodeB.keyableInt\n        # But not in the same order\n        nodeB.keyableInt.keyValues.add(\"2\", 2)\n        nodeB.keyableInt.keyValues.add(\"0\", 0)\n        nodeB.keyableInt.keyValues.add(\"1\", 1)\n\n        # Check UID, should be the same\n        assert nodeA.keyableInt.uid() == nodeB.keyableInt.uid()\n\n        # Remove (key, value) at key \"1\" from nodeA.keyableInt\n        nodeA.keyableInt.keyValues.remove(\"1\")\n\n        # Check UID, should not be the same\n        assert nodeA.keyableInt.uid() != nodeB.keyableInt.uid()"
  },
  {
    "path": "tests/test_attributeLambda.py",
    "content": "from meshroom.core import desc\nfrom meshroom.core.graph import Graph\n\nfrom .utils import registerNodeDesc, unregisterNodeDesc\n\n\nclass NodeWithCallableValue(desc.Node):\n    \"\"\"Test node with callable default values to test executeValue.\"\"\"\n    inputs = [\n        desc.IntParam(\n            name=\"fixedInput\",\n            label=\"Fixed Input\",\n            description=\"A simple integer input.\",\n            value=10,\n            range=(0, 100, 1),\n        ),\n        desc.IntParam(\n            name=\"callableNodeInput\",\n            label=\"Callable Node Input\",\n            description=\"Input with a callable default that receives the node.\",\n            value=lambda node: node.fixedInput.value * 2,\n            range=(0, 200, 1),\n        ),\n        desc.StringParam(\n            name=\"callableAttrInput\",\n            label=\"Callable Attr Input (Compatibility)\",\n            description=\"Input with a callable default that receives the attribute for compatibility with the old behavior.\",\n            value=lambda attr: f\"attr_{attr.name}\",\n        ),\n    ]\n    outputs = [\n        desc.File(\n            name=\"output\",\n            label=\"Output\",\n            description=\"\",\n            value=\"{nodeCacheFolder}/output.txt\",\n        )\n    ]\n\n\nclass TestExecuteValue:\n    \"\"\"Tests for the Attribute.executeValue method and callable value handling.\"\"\"\n\n    @classmethod\n    def setup_class(cls):\n        registerNodeDesc(NodeWithCallableValue)\n\n    @classmethod\n    def teardown_class(cls):\n        unregisterNodeDesc(NodeWithCallableValue)\n\n    def test_executeValue_with_node_parameter(self):\n        \"\"\"executeValue should pass the node when the callable parameter is named 'node'.\"\"\"\n        graph = Graph(\"\")\n        node = graph.addNewNode(\"NodeWithCallableValue\")\n\n        result = node.callableNodeInput.executeValue(lambda node: node.fixedInput.value + 5)\n        assert result == 15\n\n    def test_executeValue_with_attr_parameter(self):\n        \"\"\"executeValue should pass the attribute when the parameter is not named 'node'.\"\"\"\n        graph = Graph(\"\")\n        node = graph.addNewNode(\"NodeWithCallableValue\")\n\n        result = node.fixedInput.executeValue(lambda attr: attr.name)\n        assert result == \"fixedInput\"\n\n    def test_callable_default_value_with_node_param(self):\n        \"\"\"getDefaultValue should evaluate a callable descriptor value using node parameter.\"\"\"\n        graph = Graph(\"\")\n        node = graph.addNewNode(\"NodeWithCallableValue\")\n\n        # The default value for callableNodeInput is lambda node: node.fixedInput.value * 2\n        default = node.callableNodeInput.getDefaultValue()\n        assert default == 20  # 10 * 2\n\n    def test_callable_default_value_with_attr_param(self):\n        \"\"\"getDefaultValue should evaluate a callable descriptor value using attr parameter.\"\"\"\n        graph = Graph(\"\")\n        node = graph.addNewNode(\"NodeWithCallableValue\")\n\n        # The default value for callableAttrInput is lambda attr: f\"attr_{attr.name}\"\n        default = node.callableAttrInput.getDefaultValue()\n        assert default == \"attr_callableAttrInput\"\n\n    def test_set_value_with_callable(self):\n        \"\"\"Setting a callable value should evaluate it via executeValue.\"\"\"\n        graph = Graph(\"\")\n        node = graph.addNewNode(\"NodeWithCallableValue\")\n\n        node.fixedInput.value = lambda node: 42\n        assert node.fixedInput.value == 42\n\n    def test_set_value_with_attr_callable(self):\n        \"\"\"Setting a callable value using attr parameter should evaluate correctly.\"\"\"\n        graph = Graph(\"\")\n        node = graph.addNewNode(\"NodeWithCallableValue\")\n\n        node.fixedInput.value = lambda attr: 99\n        assert node.fixedInput.value == 99\n\n    def test_callable_default_reflects_current_state(self):\n        \"\"\"Callable default values should reflect the current node state when evaluated.\"\"\"\n        graph = Graph(\"\")\n        node = graph.addNewNode(\"NodeWithCallableValue\")\n\n        # Change the fixedInput value\n        node.fixedInput.value = 25\n\n        # Re-evaluate the callable default for callableNodeInput\n        default = node.callableNodeInput.getDefaultValue()\n        assert default == 50  # 25 * 2\n\n    def test_reset_to_default_with_callable(self):\n        \"\"\"resetToDefaultValue should correctly evaluate callable defaults.\"\"\"\n        graph = Graph(\"\")\n        node = graph.addNewNode(\"NodeWithCallableValue\")\n\n        # Change value away from default\n        node.callableAttrInput.value = \"custom_value\"\n        assert node.callableAttrInput.value == \"custom_value\"\n\n        # Reset should re-evaluate the callable\n        node.callableAttrInput.resetToDefaultValue()\n        assert node.callableAttrInput.value == \"attr_callableAttrInput\"\n"
  },
  {
    "path": "tests/test_attributeShape.py",
    "content": "from meshroom.core import desc\nfrom meshroom.core.graph import Graph\n\nfrom .utils import registerNodeDesc, unregisterNodeDesc\n\n\nclass NodeWithShapeAttributes(desc.Node):\n    inputs = [\n        desc.ShapeList(\n            name=\"pointList\",\n            label=\"Point 2d List\",\n            description=\"Point 2d list.\",\n            shape=desc.Point2d(\n                name=\"point\",\n                label=\"Point\",\n                description=\"A 2d point.\",\n            ),\n        ),\n        desc.ShapeList(\n            name=\"keyablePointList\",\n            label=\"Keyable Point 2d List\",\n            description=\"Keyable point 2d list.\",\n            shape=desc.Point2d(\n                name=\"point\",\n                label=\"Point\",\n                description=\"A 2d point.\",\n                keyable=True,\n                keyType=\"viewId\"\n            ),\n        ),\n        desc.Point2d(\n            name=\"point\",\n            label=\"Point 2d\",\n            description=\"A 2d point.\",\n        ),\n        desc.Point2d(\n            name=\"keyablePoint\",\n            label=\"Keyable Point 2d\",\n            description=\"A keyable 2d point.\",\n            keyable=True,\n            keyType=\"viewId\"\n        ),\n        desc.Line2d(\n            name=\"line\",\n            label=\"Line 2d\",\n            description=\"A 2d line.\",\n        ),\n        desc.Line2d(\n            name=\"keyableLine\",\n            label=\"Keyable Line 2d\",\n            description=\"A keyable 2d line.\",\n            keyable=True,\n            keyType=\"viewId\"\n        ),\n        desc.Rectangle(\n            name=\"rectangle\",\n            label=\"Rectangle\",\n            description=\"A rectangle.\",\n        ),\n        desc.Rectangle(\n            name=\"keyableRectangle\",\n            label=\"Keyable Rectangle\",\n            description=\"A keyable rectangle.\",\n            keyable=True,\n            keyType=\"viewId\"\n        ),\n        desc.Circle(\n            name=\"circle\",\n            label=\"Circle\",\n            description=\"A circle.\",\n        ),\n        desc.Circle(\n            name=\"keyableCircle\",\n            label=\"Keyable Circle\",\n            description=\"A keyable circle.\",\n            keyable=True,\n            keyType=\"viewId\"\n        ),\n    ]\n\nclass TestShapeAttribute:\n\n    @classmethod\n    def setup_class(cls):\n        registerNodeDesc(NodeWithShapeAttributes)\n\n    @classmethod\n    def teardown_class(cls):\n        unregisterNodeDesc(NodeWithShapeAttributes)\n\n    def test_initialization(self):\n        graph = Graph(\"\")\n        node = graph.addNewNode(NodeWithShapeAttributes.__name__)\n\n        # ShapeListAttribute initialization\n\n        # Check attribute has displayable shape (should be true)\n        assert node.pointList.hasDisplayableShape\n        assert node.keyablePointList.hasDisplayableShape\n\n        # Check attribute type\n        assert node.pointList.type == \"ShapeList\"\n        assert node.keyablePointList.type == \"ShapeList\"\n\n        # Check length\n        # Should be 0, empty list\n        assert len(node.pointList) == 0\n        assert len(node.keyablePointList) == 0\n\n        # ShapeAttribute initialization\n\n        # Check attribute has displayable shape (should be true)\n        assert node.point.hasDisplayableShape\n        assert node.line.hasDisplayableShape\n        assert node.rectangle.hasDisplayableShape\n        assert node.circle.hasDisplayableShape\n        assert node.keyablePoint.hasDisplayableShape\n        assert node.keyableLine.hasDisplayableShape\n        assert node.keyableRectangle.hasDisplayableShape\n        assert node.keyableCircle.hasDisplayableShape\n\n        # Check attribute type\n        assert node.point.type == \"Point2d\"\n        assert node.line.type == \"Line2d\"\n        assert node.rectangle.type == \"Rectangle\"\n        assert node.circle.type == \"Circle\"\n        assert node.keyablePoint.type == \"Point2d\"\n        assert node.keyableLine.type == \"Line2d\"\n        assert node.keyableRectangle.type == \"Rectangle\"\n        assert node.keyableCircle.type == \"Circle\"\n\n        # Check attribute geometry number of observations\n        # Should be 1 for static shape (default)\n        assert node.point.geometry.nbObservations == 1\n        assert node.line.geometry.nbObservations == 1\n        assert node.rectangle.geometry.nbObservations == 1\n        assert node.circle.geometry.nbObservations == 1\n        # Should be 0 for keyable shape\n        assert node.keyablePoint.geometry.nbObservations == 0\n        assert node.keyableLine.geometry.nbObservations == 0\n        assert node.keyableRectangle.geometry.nbObservations == 0\n        assert node.keyableCircle.geometry.nbObservations == 0\n\n        # Check shape attribute geometry observation keyable\n        # Should be false for static shape\n        assert not node.point.geometry.observationKeyable\n        assert not node.line.geometry.observationKeyable\n        assert not node.rectangle.geometry.observationKeyable\n        assert not node.circle.geometry.observationKeyable\n        # Should be true for keyable shape\n        assert node.keyablePoint.geometry.observationKeyable\n        assert node.keyableLine.geometry.observationKeyable\n        assert node.keyableRectangle.geometry.observationKeyable\n        assert node.keyableCircle.geometry.observationKeyable\n\n\n    def test_staticShapeGeometry(self):\n        graph = Graph(\"\")\n        node = graph.addNewNode(NodeWithShapeAttributes.__name__)\n\n        observationPoint = {\"x\": 1, \"y\": 1}\n        observationLine = {\"a\": {\"x\": 1, \"y\": 1}, \"b\": {\"x\": 2, \"y\": 2}}\n        observationRectangle = {\"center\": {\"x\": 10, \"y\": 10}, \"size\": {\"width\": 20, \"height\": 20}}\n        observationCircle = {\"center\": {\"x\": 10, \"y\": 10}, \"radius\": 20}\n\n        # Check static shape has observation, should be true (default)\n        assert node.point.geometry.hasObservation(\"0\")\n        assert node.line.geometry.hasObservation(\"0\")\n        assert node.rectangle.geometry.hasObservation(\"0\")\n        assert node.circle.geometry.hasObservation(\"0\")\n\n        # Check static shape get observation, should be default value\n        assert node.point.geometry.getObservation(\"0\") == node.point.geometry.getDefaultValue()\n        assert node.line.geometry.getObservation(\"0\") == node.line.geometry.getDefaultValue()\n        assert node.rectangle.geometry.getObservation(\"0\") == node.rectangle.geometry.getDefaultValue()\n        assert node.circle.geometry.getObservation(\"0\") == node.circle.geometry.getDefaultValue()\n\n        # Create observation at key \"0\"\n        # For static shape key has no effect\n        node.point.geometry.setObservation(\"0\", observationPoint)\n        node.line.geometry.setObservation(\"0\", observationLine)\n        node.rectangle.geometry.setObservation(\"0\", observationRectangle)\n        node.circle.geometry.setObservation(\"0\", observationCircle)\n\n        # Check static shape has observation, should be true\n        assert node.point.geometry.hasObservation(\"0\")\n        assert node.line.geometry.hasObservation(\"0\")\n        assert node.rectangle.geometry.hasObservation(\"0\")\n        assert node.circle.geometry.hasObservation(\"0\")\n\n        # Check static shape get observation, should be created observation\n        assert node.point.geometry.getObservation(\"0\") == observationPoint\n        assert node.line.geometry.getObservation(\"0\") == observationLine\n        assert node.rectangle.geometry.getObservation(\"0\") == observationRectangle\n        assert node.circle.geometry.getObservation(\"0\") == observationCircle\n\n        # Update static shape observation\n        node.point.geometry.setObservation(\"0\", {\"x\": 2})\n        node.line.geometry.setObservation(\"0\", {\"a\": {\"x\": 2, \"y\": 2}})\n        node.rectangle.geometry.setObservation(\"0\", {\"center\": {\"x\": 20, \"y\": 20}})\n        node.circle.geometry.setObservation(\"0\", {\"radius\": 40})\n\n        # Check static shape get observation, should be updated observation\n        assert node.point.geometry.getObservation(\"0\").get(\"x\") == 2\n        assert node.line.geometry.getObservation(\"0\").get(\"a\") == {\"x\": 2, \"y\": 2}\n        assert node.rectangle.geometry.getObservation(\"0\").get(\"center\") == {\"x\": 20, \"y\": 20}\n        assert node.circle.geometry.getObservation(\"0\").get(\"radius\") == 40\n\n        # Reset static shape geometry\n        node.point.geometry.resetToDefaultValue()\n        node.line.geometry.resetToDefaultValue()\n        node.rectangle.geometry.resetToDefaultValue()\n        node.circle.geometry.resetToDefaultValue()\n\n        # Check static shape get observation, should be default value\n        assert node.point.geometry.getObservation(\"0\") == node.point.geometry.getDefaultValue()\n        assert node.line.geometry.getObservation(\"0\") == node.line.geometry.getDefaultValue()\n        assert node.rectangle.geometry.getObservation(\"0\") == node.rectangle.geometry.getDefaultValue()\n        assert node.circle.geometry.getObservation(\"0\") == node.circle.geometry.getDefaultValue()\n\n\n    def test_keyableShapeGeometry(self):\n        graph = Graph(\"\")\n        node = graph.addNewNode(NodeWithShapeAttributes.__name__)\n\n        observationPoint = {\"x\": 1, \"y\": 1}\n        observationLine = {\"a\": {\"x\": 1, \"y\": 1}, \"b\": {\"x\": 2, \"y\": 2}}\n        observationRectangle = {\"center\": {\"x\": 10, \"y\": 10}, \"size\": {\"width\": 20, \"height\": 20}}\n        observationCircle = {\"center\": {\"x\": 10, \"y\": 10}, \"radius\": 20}\n\n        # Check keyable shape has observation at key \"0\", should be false\n        assert not node.keyablePoint.geometry.hasObservation(\"0\")\n        assert not node.keyableLine.geometry.hasObservation(\"0\")\n        assert not node.keyableRectangle.geometry.hasObservation(\"0\")\n        assert not node.keyableCircle.geometry.hasObservation(\"0\")\n\n        # Check keyable shape get observation at key \"0\", should be None (no observation)\n        assert node.keyablePoint.geometry.getObservation(\"0\") == None\n        assert node.keyableLine.geometry.getObservation(\"0\") == None\n        assert node.keyableRectangle.geometry.getObservation(\"0\") == None\n        assert node.keyableCircle.geometry.getObservation(\"0\") == None\n\n        # Create observation at key \"0\"\n        node.keyablePoint.geometry.setObservation(\"0\", observationPoint)\n        node.keyableLine.geometry.setObservation(\"0\", observationLine)\n        node.keyableRectangle.geometry.setObservation(\"0\", observationRectangle)\n        node.keyableCircle.geometry.setObservation(\"0\", observationCircle)\n\n        # Check keyable shape number of observations, should be 1\n        assert node.keyablePoint.geometry.nbObservations == 1\n        assert node.keyableLine.geometry.nbObservations == 1\n        assert node.keyableRectangle.geometry.nbObservations == 1\n        assert node.keyableCircle.geometry.nbObservations == 1\n\n        # Create observation at key \"1\"\n        node.keyablePoint.geometry.setObservation(\"1\", observationPoint)\n        node.keyableLine.geometry.setObservation(\"1\", observationLine)\n        node.keyableRectangle.geometry.setObservation(\"1\", observationRectangle)\n        node.keyableCircle.geometry.setObservation(\"1\", observationCircle)\n\n        # Check keyable shape number of observations, should be 2\n        assert node.keyablePoint.geometry.nbObservations == 2\n        assert node.keyableLine.geometry.nbObservations == 2\n        assert node.keyableRectangle.geometry.nbObservations == 2\n        assert node.keyableCircle.geometry.nbObservations == 2\n\n        # Check keyable shape has observation, should be true\n        assert node.keyablePoint.geometry.hasObservation(\"0\")\n        assert node.keyablePoint.geometry.hasObservation(\"1\")\n        assert node.keyableLine.geometry.hasObservation(\"0\")\n        assert node.keyableLine.geometry.hasObservation(\"1\")\n        assert node.keyableRectangle.geometry.hasObservation(\"0\")\n        assert node.keyableRectangle.geometry.hasObservation(\"1\")\n        assert node.keyableCircle.geometry.hasObservation(\"0\")\n        assert node.keyableCircle.geometry.hasObservation(\"1\")\n\n        # Check keyable shape get observation at key \"0\", should be created observation\n        assert node.keyablePoint.geometry.getObservation(\"0\") == observationPoint\n        assert node.keyableLine.geometry.getObservation(\"0\") == observationLine\n        assert node.keyableRectangle.geometry.getObservation(\"0\") == observationRectangle\n        assert node.keyableCircle.geometry.getObservation(\"0\") == observationCircle\n\n        # Update keyable shape observation at key \"1\"\n        node.keyablePoint.geometry.setObservation(\"1\", {\"x\": 2})\n        node.keyableLine.geometry.setObservation(\"1\", {\"a\": {\"x\": 2, \"y\": 2}})\n        node.keyableRectangle.geometry.setObservation(\"1\", {\"center\": {\"x\": 20, \"y\": 20}})\n        node.keyableCircle.geometry.setObservation(\"1\", {\"radius\": 40})\n\n        # Check keyable shape get observation at key \"1\", should be updated observation\n        assert node.keyablePoint.geometry.getObservation(\"1\").get(\"x\") == 2\n        assert node.keyableLine.geometry.getObservation(\"1\").get(\"a\") == {\"x\": 2, \"y\": 2}\n        assert node.keyableRectangle.geometry.getObservation(\"1\").get(\"center\") == {\"x\": 20, \"y\": 20}\n        assert node.keyableCircle.geometry.getObservation(\"1\").get(\"radius\") == 40\n\n        # Remove keyable shape observation at key \"0\"\n        node.keyablePoint.geometry.removeObservation(\"0\")\n        node.keyableLine.geometry.removeObservation(\"0\")\n        node.keyableRectangle.geometry.removeObservation(\"0\")\n        node.keyableCircle.geometry.removeObservation(\"0\")\n\n        # Check keyable shape has observation at key \"0\", should be false\n        assert not node.keyablePoint.geometry.hasObservation(\"0\")\n        assert not node.keyableLine.geometry.hasObservation(\"0\")\n        assert not node.keyableRectangle.geometry.hasObservation(\"0\")\n        assert not node.keyableCircle.geometry.hasObservation(\"0\")\n\n        # Reset keyable shape geometry\n        node.keyablePoint.geometry.resetToDefaultValue()\n        node.keyableLine.geometry.resetToDefaultValue()\n        node.keyableRectangle.geometry.resetToDefaultValue()\n        node.keyableCircle.geometry.resetToDefaultValue()\n\n        # Check keyable shape has observation at key \"1\", should be false\n        assert not node.keyablePoint.geometry.hasObservation(\"0\")\n        assert not node.keyableLine.geometry.hasObservation(\"0\")\n        assert not node.keyableRectangle.geometry.hasObservation(\"0\")\n        assert not node.keyableCircle.geometry.hasObservation(\"0\")\n\n        # Check keyable shape number of observations, should be 0\n        assert node.keyablePoint.geometry.nbObservations == 0\n        assert node.keyableLine.geometry.nbObservations == 0\n        assert node.keyableRectangle.geometry.nbObservations == 0\n        assert node.keyableCircle.geometry.nbObservations == 0\n\n    def test_shapeList(self):\n        graph = Graph(\"\")\n        node = graph.addNewNode(NodeWithShapeAttributes.__name__)\n\n        pointValue = {\"userName\": \"testPoint\", \"userColor\": \"#fff\", \"geometry\": {\"x\": 1, \"y\": 1}}\n        keyablePointValue = {\"userName\": \"testKeyablePoint\", \"userColor\": \"#fff\", \"geometry\": {}}\n\n        # Check visibility\n        assert node.pointList.isVisible\n        assert node.keyablePointList.isVisible\n\n        # Check number of shapes, should be 0 (no shape)\n        assert len(node.pointList) == 0\n        assert len(node.keyablePointList) == 0\n\n        # Add 3 elements\n        node.pointList.append(pointValue)\n        node.pointList.append(pointValue)\n        node.pointList.append(pointValue)\n        node.keyablePointList.append(keyablePointValue)\n        node.keyablePointList.append(keyablePointValue)\n        node.keyablePointList.append(keyablePointValue)\n\n        # Check number of shapes, should be 3\n        assert len(node.pointList) == 3\n        assert len(node.keyablePointList) == 3\n\n        # Check attribute second element\n        assert node.pointList.at(1).geometry.getValueAsDict() == pointValue.get(\"geometry\")\n        assert node.keyablePointList.at(1).geometry.getValueAsDict() == keyablePointValue.get(\"geometry\")\n\n        # Change visibility\n        node.pointList.isVisible = False\n        node.keyablePointList.isVisible = False\n\n        # Check shapes visibility\n        assert not node.pointList.at(0).isVisible\n        assert not node.pointList.at(1).isVisible\n        assert not node.pointList.at(2).isVisible\n        assert not node.keyablePointList.at(0).isVisible\n        assert not node.keyablePointList.at(1).isVisible\n        assert not node.keyablePointList.at(2).isVisible\n\n        # Reset shape lists\n        node.pointList.resetToDefaultValue()\n        node.keyablePointList.resetToDefaultValue()\n\n        # Check number of shapes, should be 0 (no shape)\n        assert len(node.pointList) == 0\n        assert len(node.keyablePointList) == 0\n\n\n    def test_linkAttribute(self):\n        graph = Graph(\"\")\n        nodeA = graph.addNewNode(NodeWithShapeAttributes.__name__)\n        nodeB = graph.addNewNode(NodeWithShapeAttributes.__name__)\n\n        pointGeometryValue = {\"x\": 1, \"y\": 1}\n        pointValue = {\"userName\": \"testPoint\", \"userColor\": \"#fff\", \"geometry\": pointGeometryValue}\n\n        # Add link:\n        # nodeB.pointList is a link for nodeA.pointList\n        nodeA.pointList.connectTo(nodeB.pointList)\n        # nodeB.point is a link for nodeA.point\n        nodeA.point.connectTo(nodeB.point)\n\n        # Check link\n        assert nodeB.pointList.isLink == True\n        assert nodeB.pointList.inputLink == nodeA.pointList\n        assert nodeB.point.isLink == True\n        assert nodeB.point.inputLink == nodeA.point\n\n        # Set observation for nodeA.point\n        nodeA.point.geometry.setObservation(\"0\", pointGeometryValue)\n        # Add 3 shape to nodeA.pointList\n        nodeA.pointList.append(pointValue)\n        nodeA.pointList.append(pointValue)\n        nodeA.pointList.append(pointValue)\n\n        # Check nodeB.point geometry\n        assert nodeB.point.geometry.getObservation(0) == pointGeometryValue\n\n        # Check nodeB.pointList geometry\n        assert len(nodeB.pointList) == 3\n        assert nodeB.pointList.at(0).geometry.getValueAsDict() == pointGeometryValue\n        assert nodeB.pointList.at(1).geometry.getValueAsDict() == pointGeometryValue\n        assert nodeB.pointList.at(2).geometry.getValueAsDict() == pointGeometryValue\n\n        # Update nodeA.point and nodeA.pointList[1] geometry\n        nodeA.point.geometry.setObservation(\"0\", {\"x\": 2})\n        nodeA.pointList.at(1).geometry.setObservation(\"0\", {\"x\": 2})\n\n        # Check nodeB second shape geometry\n        assert nodeB.point.geometry.getObservation(\"0\").get(\"x\") == 2\n        assert nodeB.pointList.at(1).geometry.getObservation(\"0\").get(\"x\") == 2\n\n        # Check serialized value\n        assert nodeB.point.getSerializedValue() == nodeA.point.asLinkExpr()\n        assert nodeB.pointList.getSerializedValue() == nodeA.pointList.asLinkExpr()\n\n\n    def test_exportDict(self):\n        graph = Graph(\"\")\n        node = graph.addNewNode(NodeWithShapeAttributes.__name__)\n\n        observationPoint = {\"x\": 1, \"y\": 1}\n        observationLine = {\"a\": {\"x\": 1, \"y\": 1}, \"b\": {\"x\": 2, \"y\": 2}}\n        observationRectangle = {\"center\": {\"x\": 10, \"y\": 10}, \"size\": {\"width\": 20, \"height\": 20}}\n        observationCircle = {\"center\": {\"x\": 10, \"y\": 10}, \"radius\": 20}\n\n        pointValue = {\"userName\": \"testPoint\", \"userColor\": \"#fff\", \"geometry\": observationPoint}\n        keyablePointGeometryValue = {\"x\": {\"0\": observationPoint.get(\"x\")}, \"y\": {\"0\": observationPoint.get(\"y\")}}\n        keyablePointValue = {\"userName\": \"testKeyablePoint\", \"userColor\": \"#fff\", \"geometry\": keyablePointGeometryValue}\n\n        # Check uninitialized shape attribute\n        # Shape list attribute should be empty list\n        assert node.pointList.getGeometriesAsDict() == []\n        assert node.keyablePointList.getGeometriesAsDict() == []\n        assert node.pointList.getShapesAsDict() == []\n        assert node.keyablePointList.getShapesAsDict() == []\n        # Static shape attribute should be default\n        assert node.point.geometry.getValueAsDict() == {\"x\": -1, \"y\": -1}\n        assert node.line.geometry.getValueAsDict() == {\"a\": {\"x\": -1, \"y\": -1}, \"b\": {\"x\": -1, \"y\": -1}}\n        assert node.rectangle.geometry.getValueAsDict() == {\"center\": {\"x\": -1, \"y\": -1}, \"size\": {\"width\": -1, \"height\": -1}}\n        assert node.circle.geometry.getValueAsDict() == {\"center\": {\"x\": -1, \"y\": -1}, \"radius\": -1}\n        assert node.point.getShapeAsDict() == {\"name\": node.point.rootName,\n                                               \"type\": node.point.type,\n                                               \"properties\": {\"color\": node.point.userColor, \"x\": -1, \"y\": -1}}\n        assert node.line.getShapeAsDict() == {\"name\": node.line.rootName,\n                                              \"type\": node.line.type,\n                                              \"properties\": {\"color\": node.line.userColor, \"a\": {\"x\": -1, \"y\": -1}, \"b\": {\"x\": -1, \"y\": -1}}}\n        assert node.rectangle.getShapeAsDict() == {\"name\": node.rectangle.rootName,\n                                                   \"type\": node.rectangle.type,\n                                                   \"properties\": {\"color\": node.rectangle.userColor, \"center\": {\"x\": -1, \"y\": -1}, \"size\": {\"width\": -1, \"height\": -1}}}\n        assert node.circle.getShapeAsDict() == {\"name\": node.circle.rootName,\n                                                \"type\": node.circle.type,\n                                                \"properties\": {\"color\": node.circle.userColor, \"center\": {\"x\": -1, \"y\": -1}, \"radius\": -1}}\n        # Keyable shape attribute should be empty dict\n        assert node.keyablePoint.geometry.getValueAsDict() == {}\n        assert node.keyableLine.geometry.getValueAsDict() == {}\n        assert node.keyableRectangle.geometry.getValueAsDict() == {}\n        assert node.keyableCircle.geometry.getValueAsDict() == {}\n        assert node.keyablePoint.getShapeAsDict() == {\"name\": node.keyablePoint.rootName,\n                                                      \"type\": node.keyablePoint.type,\n                                                      \"properties\": {\"color\": node.keyablePoint.userColor},\n                                                      \"observations\": {}}\n        assert node.keyableLine.getShapeAsDict() == {\"name\": node.keyableLine.rootName,\n                                                     \"type\": node.keyableLine.type,\n                                                     \"properties\": {\"color\": node.keyableLine.userColor},\n                                                     \"observations\": {}}\n        assert node.keyableRectangle.getShapeAsDict() == {\"name\": node.keyableRectangle.rootName,\n                                                          \"type\": node.keyableRectangle.type,\n                                                          \"properties\": {\"color\": node.keyableRectangle.userColor},\n                                                          \"observations\": {}}\n        assert node.keyableCircle.getShapeAsDict() == {\"name\": node.keyableCircle.rootName,\n                                                       \"type\": node.keyableCircle.type,\n                                                       \"properties\": {\"color\": node.keyableCircle.userColor},\n                                                       \"observations\": {}}\n\n        # Add one shape with an observation\n        node.pointList.append(pointValue)\n        node.keyablePointList.append(keyablePointValue)\n\n        # Add one observation\n        node.point.geometry.setObservation(\"0\", observationPoint)\n        node.keyablePoint.geometry.setObservation(\"0\", observationPoint)\n        node.line.geometry.setObservation(\"0\", observationLine)\n        node.keyableLine.geometry.setObservation(\"0\", observationLine)\n        node.rectangle.geometry.setObservation(\"0\", observationRectangle)\n        node.keyableRectangle.geometry.setObservation(\"0\", observationRectangle)\n        node.circle.geometry.setObservation(\"0\", observationCircle)\n        node.keyableCircle.geometry.setObservation(\"0\", observationCircle)\n\n        # Check shape attribute\n        # Shape list attribute should be empty dict\n        assert node.pointList.getGeometriesAsDict() == [observationPoint]\n        assert node.keyablePointList.getGeometriesAsDict() == [{\"0\": observationPoint}]\n        assert node.pointList.getShapesAsDict()[0].get(\"properties\") == {\"color\": pointValue.get(\"userColor\")} | observationPoint\n        assert node.keyablePointList.getShapesAsDict()[0].get(\"observations\") == {\"0\": observationPoint}\n        # Not keyable shape attribute should be default\n        assert node.point.geometry.getValueAsDict() == observationPoint\n        assert node.line.geometry.getValueAsDict() == observationLine\n        assert node.rectangle.geometry.getValueAsDict() == observationRectangle\n        assert node.circle.geometry.getValueAsDict() == observationCircle\n        assert node.point.getShapeAsDict().get(\"properties\") ==  {\"color\": node.point.userColor} | observationPoint\n        assert node.line.getShapeAsDict().get(\"properties\") == {\"color\": node.line.userColor} | observationLine\n        assert node.rectangle.getShapeAsDict().get(\"properties\") == {\"color\": node.rectangle.userColor} | observationRectangle\n        assert node.circle.getShapeAsDict().get(\"properties\") == {\"color\": node.circle.userColor} | observationCircle\n        # Keyable shape attribute should be empty dict\n        assert node.keyablePoint.geometry.getValueAsDict() == {\"0\": observationPoint}\n        assert node.keyableLine.geometry.getValueAsDict() == {\"0\": observationLine}\n        assert node.keyableRectangle.geometry.getValueAsDict() == {\"0\": observationRectangle}\n        assert node.keyableCircle.geometry.getValueAsDict() == {\"0\": observationCircle}\n        assert node.keyablePoint.getShapeAsDict().get(\"observations\") == {\"0\": observationPoint}\n        assert node.keyableLine.getShapeAsDict().get(\"observations\") == {\"0\": observationLine}\n        assert node.keyableRectangle.getShapeAsDict().get(\"observations\") == {\"0\": observationRectangle}\n        assert node.keyableCircle.getShapeAsDict().get(\"observations\") == {\"0\": observationCircle}"
  },
  {
    "path": "tests/test_attributes.py",
    "content": "from meshroom.core.graph import Graph\nimport pytest\n\nimport logging\nlogger = logging.getLogger('test')\n\nvalid3DExtensionFiles = [(f'test.{ext}', True) for ext in ('obj', 'stl', 'fbx', 'gltf', 'abc', 'ply')]\ninvalid3DExtensionFiles = [(f'test.{ext}', False) for ext in ('', 'exe', 'jpg', 'png', 'py')]\n\nvalid2DSemantics= [(semantic, True) for semantic in ('image', 'imageList', 'sequence')]\ninvalid2DSemantics = [(semantic, False) for semantic in ('3d', '', 'multiline', 'color/hue')]\n\nvalidTextExtensionFiles = [(f'test{ext}', True) for ext in ('.txt', '.json', '.log', '.csv', '.md')]\ninvalidTextExtensionFiles = [(f'test{ext}', False) for ext in ('', '.exe', '.jpg', '.obj', '.py')]\n\n\ndef test_attribute_retrieve_linked_input_and_output_attributes():\n    \"\"\"\n    Check that an attribute can retrieve the linked input and output attributes\n    \"\"\"\n\n    # n0 -- n1 -- n2\n    #   \\          \\\n    #    ---------- n3\n\n    g = Graph('')\n    n0 = g.addNewNode('Ls', input='')\n    n1 = g.addNewNode('Ls', input=n0.output)\n    n2 = g.addNewNode('Ls', input=n1.output)\n    n3 = g.addNewNode('AppendFiles', input=n1.output, input2=n2.output)\n\n    # check that the attribute can retrieve its linked input attributes\n\n    assert n0.output.hasAnyOutputLinks\n    assert not n3.output.hasAnyOutputLinks\n\n    assert len(n0.input.allInputLinks) == 0\n    assert len(n1.input.allInputLinks) == 1\n    assert n1.input.allInputLinks[0] == n0.output\n\n    assert len(n1.output.allOutputLinks) == 2\n\n    assert n1.output.allOutputLinks[0] == n2.input\n    assert n1.output.allOutputLinks[1] == n3.input\n\n    n0.graph = None\n\n    # Bounding cases\n    assert not n0.output.hasAnyOutputLinks\n    assert len(n0.input.allInputLinks) == 0\n    assert len(n0.output.allOutputLinks) == 0\n\n\n@pytest.mark.parametrize(\"givenFile,expected\", valid3DExtensionFiles + invalid3DExtensionFiles)\ndef test_attribute_is3D_file_extensions(givenFile, expected):\n    \"\"\"\n    Check what makes an attribute a valid 3d media\n    \"\"\"\n\n    g = Graph('')\n    n0 = g.addNewNode('Ls', input='')\n\n    # Given\n    assert not n0.input.is3dDisplayable\n\n    # When\n    n0.input.value = givenFile\n\n    # Then\n    assert n0.input.is3dDisplayable == expected\n\n\ndef test_attribute_i3D_by_description_semantic():\n    \"\"\" \"\"\"\n\n    # Given\n    g = Graph('')\n    n0 = g.addNewNode('Ls', input='')\n\n    assert not n0.output.is3dDisplayable\n\n    # When\n    n0.output.desc._semantic = \"3d\"\n\n    # Then\n    assert n0.output.is3dDisplayable\n\n\n@pytest.mark.parametrize(\"givenSemantic,expected\", valid2DSemantics + invalid2DSemantics)\ndef test_attribute_is2D_file_semantic(givenSemantic, expected):\n    \"\"\"\n    Check what makes an attribute a valid 2d media\n    \"\"\"\n\n    g = Graph('')\n    n0 = g.addNewNode('Ls', input='')\n\n    # Given\n    n0.input.desc._semantic = \"\"\n    assert not n0.input.is2dDisplayable\n\n    # When\n    n0.input.desc._semantic = givenSemantic\n\n    # Then\n    assert n0.input.is2dDisplayable == expected\n\n\n@pytest.mark.parametrize(\"givenFile,expected\", validTextExtensionFiles + invalidTextExtensionFiles)\ndef test_attribute_isText_file_extensions(givenFile, expected):\n    \"\"\"\n    Check what makes an attribute a valid text file\n    \"\"\"\n\n    g = Graph('')\n    n0 = g.addNewNode('Ls', input='')\n\n    # Given\n    assert not n0.input.isTextDisplayable\n\n    # When\n    n0.input.value = givenFile\n\n    # Then\n    assert n0.input.isTextDisplayable == expected\n\n\ndef test_attribute_isText_by_description_semantic():\n    \"\"\"\n    Check that an attribute with semantic 'textFile' is considered a text file\n    \"\"\"\n\n    # Given\n    g = Graph('')\n    n0 = g.addNewNode('Ls', input='')\n\n    # The input attribute has an empty default value, so it is not text displayable\n    assert not n0.input.isTextDisplayable\n\n    # When\n    n0.input.desc._semantic = \"textFile\"\n\n    # Then\n    assert n0.input.isTextDisplayable\n"
  },
  {
    "path": "tests/test_compatibility.py",
    "content": "#!/usr/bin/env python\n# coding:utf-8\nimport tempfile\nimport os\n\nimport copy\nfrom typing import Type\nimport pytest\n\nfrom meshroom.core import desc, pluginManager\nfrom meshroom.core.plugins import NodePlugin\nfrom meshroom.core.exception import GraphCompatibilityError, NodeUpgradeError\nfrom meshroom.core.graph import Graph, loadGraph\nfrom meshroom.core.node import CompatibilityNode, CompatibilityIssue, Node\n\nfrom .utils import registeredNodeTypes, overrideNodeTypeVersion, registerNodeDesc, unregisterNodeDesc\n\n\nSampleGroupV1 = [\n    desc.IntParam(name=\"a\", label=\"a\", description=\"\", value=0, range=None),\n    desc.ListAttribute(\n        name=\"b\",\n        elementDesc=desc.FloatParam(name=\"p\", label=\"\",\n                                    description=\"\", value=0.0, range=None),\n        label=\"b\",\n        description=\"\",\n    )\n]\n\nSampleGroupV2 = [\n    desc.IntParam(name=\"a\", label=\"a\", description=\"\", value=0, range=None),\n    desc.ListAttribute(\n        name=\"b\",\n        elementDesc=desc.GroupAttribute(name=\"p\", label=\"\",\n                                        description=\"\", items=SampleGroupV1),\n        label=\"b\",\n        description=\"\",\n    )\n]\n\n# SampleGroupV3 is SampleGroupV2 with one more int parameter\nSampleGroupV3 = [\n    desc.IntParam(name=\"a\", label=\"a\", description=\"\", value=0, range=None),\n    desc.IntParam(name=\"notInSampleGroupV2\", label=\"notInSampleGroupV2\",\n                  description=\"\", value=0, range=None),\n    desc.ListAttribute(\n        name=\"b\",\n        elementDesc=desc.GroupAttribute(name=\"p\", label=\"\",\n                                        description=\"\", items=SampleGroupV1),\n        label=\"b\",\n        description=\"\",\n    )\n]\n\n\nclass SampleNodeV1(desc.Node):\n    \"\"\" Version 1 Sample Node \"\"\"\n    inputs = [\n        desc.File(name=\"input\", label=\"Input\", description=\"\", value=\"\"),\n        desc.StringParam(name=\"paramA\", label=\"ParamA\", description=\"\",\n                         value=\"\", invalidate=False)  # No impact on UID\n    ]\n    outputs = [\n        desc.File(name=\"output\", label=\"Output\", description=\"\", value=\"{nodeCacheFolder}\")\n    ]\n\n\nclass SampleNodeV2(desc.Node):\n    \"\"\" Changes from V1:\n        * 'input' has been renamed to 'in'\n    \"\"\"\n    inputs = [\n        desc.File(name=\"in\", label=\"Input\", description=\"\", value=\"\"),\n        desc.StringParam(name=\"paramA\", label=\"ParamA\", description=\"\",\n                         value=\"\", invalidate=False),  # No impact on UID\n    ]\n    outputs = [\n        desc.File(name=\"output\", label=\"Output\", description=\"\", value=\"{nodeCacheFolder}\")\n    ]\n\n\nclass SampleNodeV3(desc.Node):\n    \"\"\"\n    Changes from V3:\n        * 'paramA' has been removed'\n    \"\"\"\n    inputs = [\n        desc.File(name=\"in\", label=\"Input\", description=\"\", value=\"\"),\n    ]\n    outputs = [\n        desc.File(name=\"output\", label=\"Output\", description=\"\", value=\"{nodeCacheFolder}\")\n    ]\n\n\nclass SampleNodeV4(desc.Node):\n    \"\"\"\n    Changes from V3:\n        * 'paramA' has been added\n    \"\"\"\n    inputs = [\n        desc.File(name=\"in\", label=\"Input\", description=\"\", value=\"\"),\n        desc.ListAttribute(name=\"paramA\", label=\"ParamA\",\n                           elementDesc=desc.GroupAttribute(\n                               items=SampleGroupV1, name=\"gA\", label=\"gA\", description=\"\"),\n                           description=\"\")\n    ]\n    outputs = [\n        desc.File(name=\"output\", label=\"Output\", description=\"\", value=\"{nodeCacheFolder}\")\n    ]\n\n\nclass SampleNodeV5(desc.Node):\n    \"\"\"\n    Changes from V4:\n        * 'paramA' elementDesc has changed from SampleGroupV1 to SampleGroupV2\n    \"\"\"\n    inputs = [\n        desc.File(name=\"in\", label=\"Input\", description=\"\", value=\"\"),\n        desc.ListAttribute(name=\"paramA\", label=\"ParamA\",\n                           elementDesc=desc.GroupAttribute(\n                               items=SampleGroupV2, name=\"gA\", label=\"gA\", description=\"\"),\n                           description=\"\")\n    ]\n    outputs = [\n        desc.File(name=\"output\", label=\"Output\", description=\"\", value=\"{nodeCacheFolder}\")\n    ]\n\n\nclass SampleNodeV6(desc.Node):\n    \"\"\"\n    Changes from V5:\n        * 'paramA' elementDesc has changed from SampleGroupV2 to SampleGroupV3\n    \"\"\"\n    inputs = [\n        desc.File(name=\"in\", label=\"Input\", description=\"\", value=\"\"),\n        desc.ListAttribute(name=\"paramA\", label=\"ParamA\",\n                           elementDesc=desc.GroupAttribute(\n                               items=SampleGroupV3, name=\"gA\", label=\"gA\", description=\"\"),\n                           description=\"\")\n    ]\n    outputs = [\n        desc.File(name=\"output\", label=\"Output\", description=\"\", value=\"{nodeCacheFolder}\")\n    ]\n\n\nclass SampleInputNodeV1(desc.InputNode):\n    \"\"\" Version 1 Sample Input Node \"\"\"\n    inputs = [\n        desc.StringParam(name=\"path\", label=\"Path\", description=\"\",\n                         value=\"\", invalidate=False)  # No impact on UID\n    ]\n    outputs = [\n        desc.File(name=\"output\", label=\"Output\", description=\"\", value=\"{nodeCacheFolder}\")\n    ]\n\n\nclass SampleInputNodeV2(desc.InputNode):\n    \"\"\" Changes from V1:\n        * 'path' has been renamed to 'in'\n    \"\"\"\n    inputs = [\n        desc.StringParam(name=\"in\", label=\"path\", description=\"\",\n                         value=\"\", invalidate=False)  # No impact on UID\n    ]\n    outputs = [\n        desc.File(name=\"output\", label=\"Output\", description=\"\", value=\"{nodeCacheFolder}\")\n    ]\n\n\ndef replaceNodeTypeDesc(nodeType: str, nodeDesc: Type[desc.Node]):\n    \"\"\"Change the `nodeDesc` associated to `nodeType`.\"\"\"\n    pluginManager.getRegisteredNodePlugins()[nodeType] = NodePlugin(nodeDesc)\n\n\ndef test_unknown_node_type():\n    \"\"\"\n    Test compatibility behavior for unknown node type.\n    \"\"\"\n    registerNodeDesc(SampleNodeV1)\n    g = Graph(\"\")\n    n = g.addNewNode(\"SampleNodeV1\", input=\"/dev/null\", paramA=\"foo\")\n    graphFile = os.path.join(tempfile.mkdtemp(), \"test_unknown_node_type.mg\")\n    g.save(graphFile)\n    internalFolder = n.internalFolder\n    nodeName = n.name\n    unregisterNodeDesc(SampleNodeV1)\n\n    # Reload file\n    g = loadGraph(graphFile)\n    os.remove(graphFile)\n\n    assert len(g.nodes) == 1\n    n = g.node(nodeName)\n    # SampleNodeV1 is now an unknown type\n    # Check node instance type and compatibility issue type\n    assert isinstance(n, CompatibilityNode)\n    assert n.issue == CompatibilityIssue.UnknownNodeType\n    # Check if attributes are properly restored\n    assert len(n.attributes) == 3\n    assert n.input.isInput\n    assert n.output.isOutput\n    # Check if internal folder\n    assert n.internalFolder == internalFolder\n\n    # Upgrade cannot be performed on unknown node types\n    assert not n.canUpgrade\n    with pytest.raises(NodeUpgradeError):\n        g.upgradeNode(nodeName)\n\n\ndef test_description_conflict():\n    \"\"\"\n    Test compatibility behavior for conflicting node descriptions.\n    \"\"\"\n    # Copy registered node types to be able to restore them\n    originalNodeTypes = copy.deepcopy(pluginManager.getRegisteredNodePlugins())\n\n    nodeTypes = [SampleNodeV1, SampleNodeV2, SampleNodeV3, SampleNodeV4, SampleNodeV5]\n    nodes = []\n    g = Graph(\"\")\n\n    # Register and instantiate instances of all node types except last one\n    for nt in nodeTypes[:-1]:\n        registerNodeDesc(nt)\n        n = g.addNewNode(nt.__name__)\n\n        if nt == SampleNodeV4:\n            # Initialize list attribute with values to create a conflict with V5\n            n.paramA.value = [{'a': 0, 'b': [1.0, 2.0]}]\n\n        nodes.append(n)\n\n    graphFile = os.path.join(tempfile.mkdtemp(), \"test_description_conflict.mg\")\n    g.save(graphFile)\n\n    # Reload file as-is, ensure no compatibility issue is detected (no CompatibilityNode instances)\n    loadGraph(graphFile, strictCompatibility=True)\n\n    # Offset node types register to create description conflicts\n    # Each node type name now reference the next one's implementation\n    for i, nt in enumerate(nodeTypes[:-1]):\n        pluginManager.getRegisteredNodePlugins()[nt.__name__] = NodePlugin(nodeTypes[i + 1])\n\n    # Reload file\n    g = loadGraph(graphFile)\n    os.remove(graphFile)\n\n    assert len(g.nodes) == len(nodes)\n    for srcNode in nodes:\n        nodeName = srcNode.name\n        compatNode = g.node(srcNode.name)\n        # Node description clashes between what has been saved\n        assert isinstance(compatNode, CompatibilityNode)\n        assert srcNode.internalFolder == compatNode.internalFolder\n\n        # Case by case description conflict verification\n        if isinstance(srcNode.nodeDesc, SampleNodeV1):\n            # V1 => V2: 'input' has been renamed to 'in'\n            assert len(compatNode.attributes) == 3\n            assert list(compatNode.attributes.keys()) == [\"input\", \"paramA\", \"output\"]\n            assert hasattr(compatNode, \"input\")\n            assert not hasattr(compatNode, \"in\")\n\n            # Perform upgrade\n            upgradedNode = g.upgradeNode(nodeName)\n            assert isinstance(upgradedNode, Node) and \\\n                isinstance(upgradedNode.nodeDesc, SampleNodeV2)\n\n            assert list(upgradedNode.attributes.keys()) == [\"in\", \"paramA\", \"output\"]\n            assert not hasattr(upgradedNode, \"input\")\n            assert hasattr(upgradedNode, \"in\")\n            # Check UID has changed (not the same set of attributes)\n            assert upgradedNode.internalFolder != srcNode.internalFolder\n\n        elif isinstance(srcNode.nodeDesc, SampleNodeV2):\n            # V2 => V3: 'paramA' has been removed\n            assert len(compatNode.attributes) == 3\n            assert hasattr(compatNode, \"paramA\")\n\n            # Perform upgrade\n            upgradedNode = g.upgradeNode(nodeName)\n            assert isinstance(upgradedNode, Node) and \\\n                isinstance(upgradedNode.nodeDesc, SampleNodeV3)\n\n            assert not hasattr(upgradedNode, \"paramA\")\n            # Check UID is identical (paramA not part of UID)\n            assert upgradedNode.internalFolder == srcNode.internalFolder\n\n        elif isinstance(srcNode.nodeDesc, SampleNodeV3):\n            # V3 => V4: 'paramA' has been added\n            assert len(compatNode.attributes) == 2\n            assert not hasattr(compatNode, \"paramA\")\n\n            # Perform upgrade\n            upgradedNode = g.upgradeNode(nodeName)\n            assert isinstance(upgradedNode, Node) and \\\n                isinstance(upgradedNode.nodeDesc, SampleNodeV4)\n\n            assert hasattr(upgradedNode, \"paramA\")\n            assert isinstance(upgradedNode.paramA.desc, desc.ListAttribute)\n            # paramA child attributes invalidate UID\n            assert upgradedNode.internalFolder != srcNode.internalFolder\n\n        elif isinstance(srcNode.nodeDesc, SampleNodeV4):\n            # V4 => V5: 'paramA' elementDesc has changed from SampleGroupV1 to SampleGroupV2\n            assert len(compatNode.attributes) == 3\n            assert hasattr(compatNode, \"paramA\")\n            groupAttribute = compatNode.paramA.desc.elementDesc\n\n            assert isinstance(groupAttribute, desc.GroupAttribute)\n            # Check that Compatibility node respect SampleGroupV1 description\n            for elt in groupAttribute.items:\n                assert isinstance(elt,\n                                  next(a for a in SampleGroupV1 if a.name == elt.name).__class__)\n\n            # Perform upgrade\n            upgradedNode = g.upgradeNode(nodeName)\n            assert isinstance(upgradedNode, Node) and \\\n                isinstance(upgradedNode.nodeDesc, SampleNodeV5)\n\n            assert hasattr(upgradedNode, \"paramA\")\n            # Parameter was incompatible, value could not be restored\n            assert upgradedNode.paramA.isDefault\n            assert upgradedNode.internalFolder != srcNode.internalFolder\n        else:\n            raise ValueError(\"Unexpected node type: \" + srcNode.nodeType)\n\n    # Restore original node types\n    pluginManager._nodePlugins = originalNodeTypes\n\ndef test_upgradeAllNodes():\n    registerNodeDesc(SampleNodeV1)\n    registerNodeDesc(SampleNodeV2)\n    registerNodeDesc(SampleInputNodeV1)\n    registerNodeDesc(SampleInputNodeV2)\n\n    g = Graph(\"\")\n    n1 = g.addNewNode(\"SampleNodeV1\")\n    n2 = g.addNewNode(\"SampleNodeV2\")\n    n3 = g.addNewNode(\"SampleInputNodeV1\")\n    n4 = g.addNewNode(\"SampleInputNodeV2\")\n    n1Name = n1.name\n    n2Name = n2.name\n    n3Name = n3.name\n    n4Name = n4.name\n    graphFile = os.path.join(tempfile.mkdtemp(), \"test_description_conflict.mg\")\n    g.save(graphFile)\n\n    # Replace SampleNodeV1 by SampleNodeV2 and SampleInputNodeV1 by SampleInputNodeV2\n    pluginManager.getRegisteredNodePlugins()[SampleNodeV1.__name__] = \\\n        pluginManager.getRegisteredNodePlugin(SampleNodeV2.__name__)\n    pluginManager.getRegisteredNodePlugins()[SampleInputNodeV1.__name__] = \\\n        pluginManager.getRegisteredNodePlugin(SampleInputNodeV2.__name__)\n\n    # Make SampleNodeV2 and SampleInputNodeV2 an unknown type\n    unregisterNodeDesc(SampleNodeV2)\n    unregisterNodeDesc(SampleInputNodeV2)\n\n    # Reload file\n    g = loadGraph(graphFile)\n    os.remove(graphFile)\n\n    # Both nodes are CompatibilityNodes\n    assert len(g.compatibilityNodes) == 3\n    assert g.node(n1Name).canUpgrade      # description conflict\n    assert not g.node(n2Name).canUpgrade  # unknown type\n    assert not g.node(n4Name).canUpgrade  # unknown type\n    # Input node with a description conflict and no invalidating attribute: the upgrade can be done automatically\n    assert not g.node(n3Name).isCompatibilityNode\n\n    # Upgrade all upgradable nodes\n    g.upgradeAllNodes()\n\n    # Only the nodes with an unknown type have not been upgraded\n    assert len(g.compatibilityNodes) == 2\n    assert n2Name in g.compatibilityNodes.keys()\n    assert n4Name in g.compatibilityNodes.keys()\n\n    unregisterNodeDesc(SampleNodeV1)\n    unregisterNodeDesc(SampleInputNodeV1)\n\n\ndef test_conformUpgrade():\n    registerNodeDesc(SampleNodeV5)\n    registerNodeDesc(SampleNodeV6)\n\n    g = Graph(\"\")\n    n1 = g.addNewNode(\"SampleNodeV5\")\n    n1.paramA.value = [{\"a\": 0, \"b\": [{\"a\": 0, \"b\": [1.0, 2.0]}, {\"a\": 1, \"b\": [1.0, 2.0]}]}]\n    n1Name = n1.name\n    graphFile = os.path.join(tempfile.mkdtemp(), \"test_conform_upgrade.mg\")\n    g.save(graphFile)\n\n    # Replace SampleNodeV5 by SampleNodeV6\n    pluginManager.getRegisteredNodePlugins()[SampleNodeV5.__name__] = \\\n        pluginManager.getRegisteredNodePlugin(SampleNodeV6.__name__)\n\n    # Reload file\n    g = loadGraph(graphFile)\n    os.remove(graphFile)\n\n    # Node is a CompatibilityNode\n    assert len(g.compatibilityNodes) == 1\n    assert g.node(n1Name).canUpgrade\n\n    # Upgrade all upgradable nodes\n    g.upgradeAllNodes()\n\n    # Only the node with an unknown type has not been upgraded\n    assert len(g.compatibilityNodes) == 0\n\n    upgradedNode = g.node(n1Name)\n\n    # Check upgrade\n    assert isinstance(upgradedNode, Node) and isinstance(upgradedNode.nodeDesc, SampleNodeV6)\n\n    # Check conformation\n    assert len(upgradedNode.paramA.value) == 1\n\n    unregisterNodeDesc(SampleNodeV5)\n    unregisterNodeDesc(SampleNodeV6)\n\n\nclass TestGraphLoadingWithStrictCompatibility:\n\n    def test_failsOnUnknownNodeType(self, graphSavedOnDisk):\n        with registeredNodeTypes([SampleNodeV1]):\n            graph: Graph = graphSavedOnDisk\n            graph.addNewNode(SampleNodeV1.__name__)\n            graph.save()\n\n        with pytest.raises(GraphCompatibilityError):\n            loadGraph(graph.filepath, strictCompatibility=True)\n\n    def test_failsOnNodeDescriptionCompatibilityIssue(self, graphSavedOnDisk):\n        with registeredNodeTypes([SampleNodeV1, SampleNodeV2]):\n            graph: Graph = graphSavedOnDisk\n            graph.addNewNode(SampleNodeV1.__name__)\n            graph.save()\n\n            replaceNodeTypeDesc(SampleNodeV1.__name__, SampleNodeV2)\n\n            with pytest.raises(GraphCompatibilityError):\n                loadGraph(graph.filepath, strictCompatibility=True)\n\n\nclass TestGraphTemplateLoading:\n\n    def test_failsOnUnknownNodeTypeError(self, graphSavedOnDisk):\n        with registeredNodeTypes([SampleNodeV1, SampleNodeV2]):\n            graph: Graph = graphSavedOnDisk\n            graph.addNewNode(SampleNodeV1.__name__)\n            graph.save(template=True)\n\n        with pytest.raises(GraphCompatibilityError):\n            loadGraph(graph.filepath, strictCompatibility=True)\n\n    def test_loadsIfIncompatibleNodeHasDefaultAttributeValues(self, graphSavedOnDisk):\n        with registeredNodeTypes([SampleNodeV1, SampleNodeV2]):\n            graph: Graph = graphSavedOnDisk\n            graph.addNewNode(SampleNodeV1.__name__)\n            graph.save(template=True)\n\n            replaceNodeTypeDesc(SampleNodeV1.__name__, SampleNodeV2)\n\n            loadGraph(graph.filepath, strictCompatibility=True)\n\n    def test_loadsIfValueSetOnCompatibleAttribute(self, graphSavedOnDisk):\n        with registeredNodeTypes([SampleNodeV1, SampleNodeV2]):\n            graph: Graph = graphSavedOnDisk\n            node = graph.addNewNode(SampleNodeV1.__name__, paramA=\"foo\")\n            graph.save(template=True)\n\n            replaceNodeTypeDesc(SampleNodeV1.__name__, SampleNodeV2)\n\n            loadedGraph = loadGraph(graph.filepath, strictCompatibility=True)\n            assert loadedGraph.nodes.get(node.name).paramA.value == \"foo\"\n\n    def test_loadsIfValueSetOnIncompatibleAttribute(self, graphSavedOnDisk):\n        with registeredNodeTypes([SampleNodeV1, SampleNodeV2]):\n            graph: Graph = graphSavedOnDisk\n            graph.addNewNode(SampleNodeV1.__name__, input=\"foo\")\n            graph.save(template=True)\n\n            replaceNodeTypeDesc(SampleNodeV1.__name__, SampleNodeV2)\n\n            loadGraph(graph.filepath, strictCompatibility=True)\n\n\nclass TestVersionConflict:\n\n    def test_loadingConflictingNodeVersionCreatesCompatibilityNodes(self, graphSavedOnDisk):\n        graph: Graph = graphSavedOnDisk\n\n        with registeredNodeTypes([SampleNodeV1]):\n            with overrideNodeTypeVersion(SampleNodeV1, \"1.0\"):\n                node = graph.addNewNode(SampleNodeV1.__name__)\n                graph.save()\n\n            with overrideNodeTypeVersion(SampleNodeV1, \"2.0\"):\n                otherGraph = Graph(\"\")\n                otherGraph.load(graph.filepath)\n\n        assert len(otherGraph.compatibilityNodes) == 1\n        assert otherGraph.node(node.name).issue is CompatibilityIssue.VersionConflict\n\n    def test_loadingUnspecifiedNodeVersionAssumesCurrentVersion(self, graphSavedOnDisk):\n        graph: Graph = graphSavedOnDisk\n\n        with registeredNodeTypes([SampleNodeV1]):\n            graph.addNewNode(SampleNodeV1.__name__)\n            graph.save()\n\n            with overrideNodeTypeVersion(SampleNodeV1, \"2.0\"):\n                otherGraph = Graph(\"\")\n                otherGraph.load(graph.filepath)\n\n        assert len(otherGraph.compatibilityNodes) == 0\n\n\nclass UidTestingNodeV1(desc.Node):\n    inputs = [\n        desc.File(name=\"input\", label=\"Input\", description=\"\", value=\"\", invalidate=True),\n    ]\n    outputs = [desc.File(name=\"output\", label=\"Output\",\n                         description=\"\", value=\"{nodeCacheFolder}\")]\n\n\nclass UidTestingNodeV2(desc.Node):\n    \"\"\"\n    Changes from SampleNodeBV1:\n        * 'param' has been added\n    \"\"\"\n\n    inputs = [\n        desc.File(name=\"input\", label=\"Input\", description=\"\", value=\"\", invalidate=True),\n        desc.ListAttribute(\n            name=\"param\",\n            label=\"Param\",\n            elementDesc=desc.File(\n                name=\"file\",\n                label=\"File\",\n                description=\"\",\n                value=\"\",\n            ),\n            description=\"\",\n        ),\n    ]\n    outputs = [desc.File(name=\"output\", label=\"Output\", description=\"\", value=\"{nodeCacheFolder}\")]\n\n\nclass UidTestingNodeV3(desc.Node):\n    \"\"\"\n    Changes from SampleNodeBV2:\n        * 'input' is not invalidating the UID.\n    \"\"\"\n\n    inputs = [\n        desc.File(name=\"input\", label=\"Input\", description=\"\", value=\"\", invalidate=False),\n        desc.ListAttribute(\n            name=\"param\",\n            label=\"Param\",\n            elementDesc=desc.File(\n                name=\"file\",\n                label=\"File\",\n                description=\"\",\n                value=\"\",\n            ),\n            description=\"\",\n        ),\n    ]\n    outputs = [desc.File(name=\"output\", label=\"Output\",\n                         description=\"\", value=\"{nodeCacheFolder}\")]\n\n\nclass TestUidConflict:\n    def test_changingInvalidateOnAttributeDescCreatesUidConflict(self, graphSavedOnDisk):\n        with registeredNodeTypes([UidTestingNodeV2]):\n            graph: Graph = graphSavedOnDisk\n            node = graph.addNewNode(UidTestingNodeV2.__name__)\n\n            graph.save()\n            replaceNodeTypeDesc(UidTestingNodeV2.__name__, UidTestingNodeV3)\n\n            with pytest.raises(GraphCompatibilityError):\n                loadGraph(graph.filepath, strictCompatibility=True)\n\n            loadedGraph = loadGraph(graph.filepath)\n            loadedNode = loadedGraph.node(node.name)\n            assert isinstance(loadedNode, CompatibilityNode)\n            assert loadedNode.issue == CompatibilityIssue.UidConflict\n\n    def test_uidConflictingNodesPreserveConnectionsOnGraphLoad(self, graphSavedOnDisk):\n        with registeredNodeTypes([UidTestingNodeV2]):\n            graph: Graph = graphSavedOnDisk\n            nodeA = graph.addNewNode(UidTestingNodeV2.__name__)\n            nodeB = graph.addNewNode(UidTestingNodeV2.__name__)\n\n            nodeB.param.append(\"\")\n            nodeA.output.connectTo(nodeB.param.at(0))\n\n            graph.save()\n            replaceNodeTypeDesc(UidTestingNodeV2.__name__, UidTestingNodeV3)\n\n            loadedGraph = loadGraph(graph.filepath)\n            assert len(loadedGraph.compatibilityNodes) == 2\n\n            loadedNodeA = loadedGraph.node(nodeA.name)\n            loadedNodeB = loadedGraph.node(nodeB.name)\n\n            assert loadedNodeB.param.at(0).inputLink == loadedNodeA.output\n\n    def test_upgradingConflictingNodesPreserveConnections(self, graphSavedOnDisk):\n        with registeredNodeTypes([UidTestingNodeV2]):\n            graph: Graph = graphSavedOnDisk\n            nodeA = graph.addNewNode(UidTestingNodeV2.__name__)\n            nodeB = graph.addNewNode(UidTestingNodeV2.__name__)\n\n            # Double-connect nodeA.output to nodeB, on both a single attribute and a list attribute\n            nodeB.param.append(\"\")\n            nodeA.output.connectTo(nodeB.param.at(0))\n            nodeA.output.connectTo(nodeB.input)\n\n            graph.save()\n            replaceNodeTypeDesc(UidTestingNodeV2.__name__, UidTestingNodeV3)\n\n            def checkNodeAConnectionsToNodeB():\n                loadedNodeA = loadedGraph.node(nodeA.name)\n                loadedNodeB = loadedGraph.node(nodeB.name)\n                return (\n                    loadedNodeB.param.at(0).inputLink == loadedNodeA.output\n                    and loadedNodeB.input.inputLink == loadedNodeA.output\n                )\n\n            loadedGraph = loadGraph(graph.filepath)\n            loadedGraph.upgradeNode(nodeA.name)\n\n            assert checkNodeAConnectionsToNodeB()\n            loadedGraph.upgradeNode(nodeB.name)\n\n            assert checkNodeAConnectionsToNodeB()\n            assert len(loadedGraph.compatibilityNodes) == 0\n\n    def test_uidConflictDoesNotPropagateToValidDownstreamNodeThroughConnection(\n            self, graphSavedOnDisk):\n        with registeredNodeTypes([UidTestingNodeV1, UidTestingNodeV2]):\n            graph: Graph = graphSavedOnDisk\n            nodeA = graph.addNewNode(UidTestingNodeV2.__name__)\n            nodeB = graph.addNewNode(UidTestingNodeV1.__name__)\n\n            nodeA.output.connectTo(nodeB.input)\n\n            graph.save()\n            replaceNodeTypeDesc(UidTestingNodeV2.__name__, UidTestingNodeV3)\n\n            loadedGraph = loadGraph(graph.filepath)\n            assert len(loadedGraph.compatibilityNodes) == 1\n\n    def test_uidConflictDoesNotPropagateToValidDownstreamNodeThroughListConnection(\n            self, graphSavedOnDisk):\n        with registeredNodeTypes([UidTestingNodeV2, UidTestingNodeV3]):\n            graph: Graph = graphSavedOnDisk\n            nodeA = graph.addNewNode(UidTestingNodeV2.__name__)\n            nodeB = graph.addNewNode(UidTestingNodeV3.__name__)\n\n            nodeB.param.append(\"\")\n            nodeA.output.connectTo(nodeB.param.at(0))\n\n            graph.save()\n            replaceNodeTypeDesc(UidTestingNodeV2.__name__, UidTestingNodeV3)\n\n            loadedGraph = loadGraph(graph.filepath)\n            assert len(loadedGraph.compatibilityNodes) == 1\n"
  },
  {
    "path": "tests/test_compute.py",
    "content": "# coding:utf-8\n\n\"\"\"\nIn this test we test the code that is usually launched directly from the meshroom_compute script\n\nTODO : We could directly test by launching the executable (`desc.node._MESHROOM_COMPUTE_EXE`)\n\"\"\"\n\nimport os\nimport re\nfrom pathlib import Path\nimport logging\n\nfrom meshroom.core.graph import Graph, loadGraph\nfrom meshroom.core import desc, pluginManager, loadClassesNodes\nfrom meshroom.core.node import Status\nfrom meshroom.core.plugins import Plugin\nfrom .utils import registerNodeDesc, unregisterNodeDesc\n\nLOGGER = logging.getLogger(\"TestCompute\")\n\n\ndef executeChunks(node, size):\n    os.makedirs(node.internalFolder)\n    logFiles = {}\n    for chunkIndex in range(size):\n        iteration = chunkIndex if size > 1 else -1\n        logFileName = f\"{chunkIndex}.log\"\n        logFile = Path(node.internalFolder) / logFileName\n        logFiles[chunkIndex] = logFile\n        logFile.touch()\n        node.prepareLogger(iteration)\n        node.preprocess()\n        if size > 1:\n            chunk = node.chunks[chunkIndex]\n            chunk.process(True, True)\n        else:\n            node.process(True, True)\n        node.postprocess()\n        node.restoreLogger()\n    return logFiles\n\n\n_INPUTS = [\n    desc.IntParam(\n        name=\"input\",\n        label=\"Input\",\n        description=\"input\",\n        value=0,\n    ),\n]\n_OUTPUTS = [\n    desc.IntParam(\n        name=\"output\",\n        label=\"Output\",\n        description=\"Output\",\n        value=None,\n    ),\n]\n\nclass TestNodeA(desc.BaseNode):\n    \"\"\"\n    Test process with chunks\n    \"\"\"\n    __test__ = False\n    _size = 2\n    size = desc.StaticNodeSize(2)\n    parallelization = desc.Parallelization(blockSize=1)\n    inputs = _INPUTS\n    outputs = _OUTPUTS\n\n    def processChunk(self, chunk):\n        chunk.logManager.start(\"info\")\n        iteration = chunk.range.iteration\n        nbBlocks = chunk.range.nbBlocks\n        chunk.logger.info(f\"> (chunk.logger) {chunk.node.name}\")\n        LOGGER.info(f\"> (root logger) {iteration}/{nbBlocks}\")\n        chunk.logManager.end()\n\n\nclass TestNodeB(TestNodeA):\n    \"\"\"\n    Test process with 1 chunk but still implementing processChunk\n    \"\"\"\n    __test__ = False\n    _size = 1\n    size = desc.StaticNodeSize(1)\n    parallelization = None\n\n\nclass TestNodeC(desc.BaseNode):\n    \"\"\"\n    Test process without chunks and without processChunk\n    \"\"\"\n    __test__ = False\n    size = desc.StaticNodeSize(1)\n    parallelization = None\n    inputs = _INPUTS\n    outputs = _OUTPUTS\n\n    def process(self, node):\n        LOGGER.info(f\"> {node.name}\")\n\n\nclass TestNodeLogger:\n    \"\"\"\n    Test that the logger is correctly set up during the different stages of the compute and that logs are correctly\n    written in the log file.\n    \"\"\"\n\n    logPrefix = r\"\\[\\d{2}:\\d{2}:\\d{2}\\.\\d{3}\\]\\[info\\] > \"\n\n    @classmethod\n    def setup_class(cls):\n        registerNodeDesc(TestNodeA)\n        registerNodeDesc(TestNodeB)\n        registerNodeDesc(TestNodeC)\n\n    @classmethod\n    def teardown_class(cls):\n        unregisterNodeDesc(TestNodeA)\n        unregisterNodeDesc(TestNodeB)\n        unregisterNodeDesc(TestNodeC)\n\n    def test_processChunks(self, tmp_path):\n        graph = Graph(\"\")\n        graph._cacheDir = tmp_path\n        # TestNodeA : multiple chunks\n        node = graph.addNewNode(TestNodeA.__name__)\n        # Compute\n        logFiles = executeChunks(node, 2)\n        for chunkIndex, logFile in logFiles.items():\n            with open(logFile, \"r\") as f:\n                content = f.read()\n                reg = re.compile(self.logPrefix + r\"\\(chunk.logger\\) TestNodeA_1\")\n                assert len(reg.findall(content)) == 1\n                reg = re.compile(self.logPrefix + r\"\\(root logger\\) \" + f\"{chunkIndex}/2\")\n                assert len(reg.findall(content)) == 1\n        # TestNodeA : single chunk\n        nodeB = graph.addNewNode(TestNodeB.__name__)\n        logFiles = executeChunks(nodeB, 1)\n        for chunkIndex, logFile in logFiles.items():\n            with open(logFile, \"r\") as f:\n                content = f.read()\n                reg = re.compile(self.logPrefix + r\"\\(chunk.logger\\) TestNodeB_1\")\n                assert len(reg.findall(content)) == 1\n                reg = re.compile(self.logPrefix + r\"\\(root logger\\) 0/0\")\n                assert len(reg.findall(content)) == 1\n\n    def test_process(self, tmp_path):\n        graph = Graph(\"\")\n        graph._cacheDir = tmp_path\n        node = graph.addNewNode(TestNodeC.__name__)\n        # Compute\n        logFiles = executeChunks(node, 1)\n        for _, logFile in logFiles.items():\n            with open(logFile, \"r\") as f:\n                content = f.read()\n                reg = re.compile(self.logPrefix + \"TestNodeC_1\")\n                assert len(reg.findall(content)) == 1\n\n\nclass TestLockUpdates:\n    \"\"\"\n    Tests for node locking behaviour during status transitions. Nodes should be properly locked when they undergo\n    computation statuses and unlocked when their status is reset (through parameter changes, for example).\n    \"\"\"\n    plugin = None\n\n    @classmethod\n    def setup_class(cls):\n        folder = os.path.join(os.path.dirname(__file__), \"plugins\", \"meshroom\")\n        package = \"pluginA\"\n        cls.plugin = Plugin(package, folder)\n        nodes = loadClassesNodes(folder, package)\n        for node in nodes:\n            cls.plugin.addNodePlugin(node)\n        pluginManager.addPlugin(cls.plugin)\n\n    @classmethod\n    def teardown_class(cls):\n        for node in cls.plugin.nodes.values():\n            pluginManager.unregisterNode(node)\n        pluginManager.removePlugin(cls.plugin)\n        cls.plugin = None\n\n    @staticmethod\n    def checkNodeStatusAndLock(node, expectedStatus, expectedLock):\n        assert node.globalStatus == expectedStatus.name\n        assert node.locked == expectedLock\n\n    def test_lockDuringComputation(self, graphSavedOnDisk):\n        \"\"\"\n        Test that a node is properly locked during the execution of its \"process()\" method and unlocked once the process\n        is finished. Both the global status and the lock status should be updated throughout the process.\n        \"\"\"\n        import threading\n        import time\n\n        graph: Graph = graphSavedOnDisk\n        node = graph.addNewNode(\"PluginANodeA\")\n        graph.save()\n\n        self.checkNodeStatusAndLock(node, Status.NONE, False)\n\n        # PluginANodeA will sleep 3 seconds in its \"process\", so we can check the status and lock during the process execution\n        thread = threading.Thread(target=node.process, kwargs={\"inCurrentEnv\": True})\n        thread.start()\n\n        time.sleep(0.5)  # Wait for the process to start and update the status\n        self.checkNodeStatusAndLock(node, Status.RUNNING, True)\n\n        # Wait for the process to finish and update the status\n        thread.join()\n\n        self.checkNodeStatusAndLock(node, Status.SUCCESS, False)\n\n    def test_lockResetOnParameterChange(self, graphSavedOnDisk):\n        \"\"\"\n        Test that a node's lock is properly reset when its status is reset,\n        for example through parameter changes.\n        \"\"\"\n        graph: Graph = graphSavedOnDisk\n        node = graph.addNewNode(\"PluginANodeA\")\n        graph.save()\n\n        self.checkNodeStatusAndLock(node, Status.NONE, False)\n        node.process(inCurrentEnv=True)\n        self.checkNodeStatusAndLock(node, Status.SUCCESS, False)\n\n        # Change a parameter to reset the status and check that the lock is also reset\n        node.input.value = \"path\"\n        self.checkNodeStatusAndLock(node, Status.NONE, False)\n\n    def test_lockResetOnDuplicatedParameterChange(self, graphSavedOnDisk):\n        \"\"\"\n        Test that when a node is duplicated while running, the duplicate node is independent from the original one\n        and that changing a parameter on the duplicate node resets its status and lock without impacting the original\n        node's status and lock.\n        \"\"\"\n        import threading\n        import time\n\n        graph: Graph = graphSavedOnDisk\n        node = graph.addNewNode(\"PluginANodeA\")\n        graph.save()\n\n        self.checkNodeStatusAndLock(node, Status.NONE, False)\n\n        thread = threading.Thread(target=node.process, kwargs={\"inCurrentEnv\": True})\n        thread.start()\n\n        time.sleep(0.5)\n        self.checkNodeStatusAndLock(node, Status.RUNNING, True)\n\n        # Duplicate the running & locked node\n        duplicate = graph.duplicateNodes([node])\n\n        # \"duplicate\" is an ordered_dict with the original node as key and a list of duplicates as value.\n        # We know there is only one duplicate in this test.\n        assert len(duplicate) == 1\n        duplicate = list(duplicate.values())[0][0]\n\n        # Check the duplicate node is valid\n        assert duplicate is not None\n        assert duplicate.nodeType == node.nodeType\n        assert duplicate.name != node.name\n\n        # Check the status of the duplicate node is RUNNING but that it is not locked:\n        # it has not been computed and should be independent from the original node\n        self.checkNodeStatusAndLock(duplicate, Status.RUNNING, False)\n\n        # Change a parameter to reset the duplicatenode's status and check that the lock is also reset\n        duplicate.input.value = \"path\"\n        self.checkNodeStatusAndLock(duplicate, Status.NONE, False)\n        self.checkNodeStatusAndLock(node, Status.RUNNING, True)\n\n        thread.join()\n\n        self.checkNodeStatusAndLock(node, Status.SUCCESS, False)\n        self.checkNodeStatusAndLock(duplicate, Status.NONE, False)\n\n    def test_noLockResetOnGraphLoad(self, graphSavedOnDisk):\n        \"\"\"\n        Test that when a graph is loaded while a node is running, the node's status and lock are not reset and that\n        the node is still locked. \"\"\"\n        import threading\n        import time\n\n        graph: Graph = graphSavedOnDisk\n        node = graph.addNewNode(\"PluginANodeA\")\n        graph.save()\n        self.checkNodeStatusAndLock(node, Status.NONE, False)\n\n        thread = threading.Thread(target=node.process, kwargs={\"inCurrentEnv\": True})\n        thread.start()\n        time.sleep(0.5)\n        self.checkNodeStatusAndLock(node, Status.RUNNING, True)\n\n        # Load the graph while the node is running and check that the node's status and lock are not reset\n        loadedGraph = loadGraph(graph.filepath)\n        loadedNode = loadedGraph.node(node.name)\n        self.checkNodeStatusAndLock(loadedNode, Status.RUNNING, True)\n\n        thread.join()\n        # Make sure the status is up-to-date\n        loadedNode.updateStatusFromCache()\n        self.checkNodeStatusAndLock(loadedNode, Status.SUCCESS, False)\n\n    def test_noDownstreamNodeLockDuringComputation(self, graphSavedOnDisk):\n        \"\"\"\n        Test that when a node is running, its downstream nodes are not locked, and their status is not updated.\n        \"\"\"\n        import threading\n        import time\n\n        graph: Graph = graphSavedOnDisk\n        node = graph.addNewNode(\"PluginANodeA\")\n        downstreamNode = graph.addNewNode(\"PluginANodeB\")\n        node.output.connectTo(downstreamNode.input)\n        graph.save()\n\n        self.checkNodeStatusAndLock(node, Status.NONE, False)\n        self.checkNodeStatusAndLock(downstreamNode, Status.NONE, False)\n\n        thread = threading.Thread(target=node.process, kwargs={\"inCurrentEnv\": True})\n        thread.start()\n\n        time.sleep(0.5)\n        self.checkNodeStatusAndLock(node, Status.RUNNING, True)\n        self.checkNodeStatusAndLock(downstreamNode, Status.NONE, False)\n\n        thread.join()\n        self.checkNodeStatusAndLock(node, Status.SUCCESS, False)\n        self.checkNodeStatusAndLock(downstreamNode, Status.NONE, False)\n\n    def test_upstreamLockDuringComputation(self, graphSavedOnDisk):\n        \"\"\"\n        Test that when a node is running, its upstream nodes are locked and their status remains unchanged.\n        \"\"\"\n        import threading\n        import time\n\n        graph: Graph = graphSavedOnDisk\n        node = graph.addNewNode(\"PluginANodeA\")\n        downstreamNode = graph.addNewNode(\"PluginANodeB\")\n        node.output.connectTo(downstreamNode.input)\n        graph.save()\n\n        self.checkNodeStatusAndLock(node, Status.NONE, False)\n        self.checkNodeStatusAndLock(downstreamNode, Status.NONE, False)\n\n        node.process(inCurrentEnv=True)\n        self.checkNodeStatusAndLock(node, Status.SUCCESS, False)\n        self.checkNodeStatusAndLock(downstreamNode, Status.NONE, False)\n\n        thread = threading.Thread(target=downstreamNode.process, kwargs={\"inCurrentEnv\": True})\n        thread.start()\n        time.sleep(0.5)\n        self.checkNodeStatusAndLock(node, Status.SUCCESS, True)\n        self.checkNodeStatusAndLock(downstreamNode, Status.RUNNING, True)\n\n        thread.join()\n        self.checkNodeStatusAndLock(node, Status.SUCCESS, False)\n        self.checkNodeStatusAndLock(downstreamNode, Status.SUCCESS, False)\n\n    def test_noDownstreamLockAfterParameterChange(self, graphSavedOnDisk):\n        \"\"\"\n        Test that when a computed node's parameter is updated, the downstream node's status and lock are\n        updated accordingly.\n        \"\"\"\n        graph: Graph = graphSavedOnDisk\n        node = graph.addNewNode(\"PluginANodeA\")\n        downstreamNode = graph.addNewNode(\"PluginANodeB\")\n        node.output.connectTo(downstreamNode.input)\n        graph.save()\n\n        self.checkNodeStatusAndLock(node, Status.NONE, False)\n        self.checkNodeStatusAndLock(downstreamNode, Status.NONE, False)\n\n        node.process(inCurrentEnv=True)\n        downstreamNode.process(inCurrentEnv=True)\n\n        self.checkNodeStatusAndLock(node, Status.SUCCESS, False)\n        self.checkNodeStatusAndLock(downstreamNode, Status.SUCCESS, False)\n\n        # Change a parameter on the upstream node and check that the downstream node's status is reset but not locked\n        node.input.value = \"path\"\n        self.checkNodeStatusAndLock(node, Status.NONE, False)\n        self.checkNodeStatusAndLock(downstreamNode, Status.NONE, False)\n\n    def test_noUpstreamLockAfterParameterChange(self, graphSavedOnDisk):\n        \"\"\"\n        Test that when a computed node's parameter is updated, the upstream node's status and lock are not\n        impacted.\n        \"\"\"\n        graph: Graph = graphSavedOnDisk\n        node = graph.addNewNode(\"PluginANodeA\")\n        downstreamNode = graph.addNewNode(\"PluginANodeB\")\n        node.output.connectTo(downstreamNode.input)\n        graph.save()\n\n        self.checkNodeStatusAndLock(node, Status.NONE, False)\n        self.checkNodeStatusAndLock(downstreamNode, Status.NONE, False)\n\n        node.process(inCurrentEnv=True)\n        downstreamNode.process(inCurrentEnv=True)\n\n        self.checkNodeStatusAndLock(node, Status.SUCCESS, False)\n        self.checkNodeStatusAndLock(downstreamNode, Status.SUCCESS, False)\n\n        # Disconnect the downstream node and check that the upstream node's status is not reset and that it is not locked\n        downstreamNode.input.disconnectEdge()\n        self.checkNodeStatusAndLock(node, Status.SUCCESS, False)\n        self.checkNodeStatusAndLock(downstreamNode, Status.NONE, False)\n\n\nclass TestNode_SizeA(desc.BaseNode):\n    __test__ = False\n    size = desc.DynamicNodeSize(\"nbChunks\")\n    parallelization = desc.Parallelization(blockSize=1)\n    inputs = [\n        desc.IntParam(\n            name=\"nbChunks\",\n            label=\"nbChunks\",\n            description=\"number of chunks\",\n            value=2,\n        ),\n        desc.File(\n            name=\"nodeInput\",\n            label=\"Node Input\",\n            description=\"\",\n            value=\"\",\n        ),\n    ]\n    outputs = [\n        desc.File(\n            name='output',\n            label='Output',\n            description='Output',\n            value=os.path.join(\"{nodeCacheFolder}\"),\n            commandLineGroup='',\n        ),\n    ]\n    def processChunk(self, chunk):\n        pass\n\nclass TestNode_SizeB(TestNode_SizeA):\n    \"\"\" Inherit the linked node size but not parallelized \"\"\"\n    size = desc.DynamicNodeSize(\"nodeInput\")\n    parallelization = False\n\nclass TestNode_SizeC(TestNode_SizeA):\n    \"\"\" Inherit the linked node size and parallelized \"\"\"\n    size = desc.DynamicNodeSize(\"nodeInput\")\n    parallelization = desc.Parallelization(blockSize=1)\n\n\nclass TestSizeUpdate:\n    plugin = None\n\n    @classmethod\n    def setup_class(cls):\n        registerNodeDesc(TestNode_SizeA)\n        registerNodeDesc(TestNode_SizeB)\n        registerNodeDesc(TestNode_SizeC)\n\n    @classmethod\n    def teardown_class(cls):\n        unregisterNodeDesc(TestNode_SizeA)\n        unregisterNodeDesc(TestNode_SizeB)\n        unregisterNodeDesc(TestNode_SizeC)\n    \n    @staticmethod\n    def checkNodeSizeAndStatus(node, nodeSize, nbChunks, status):\n        assert node.size == nodeSize\n        assert len(node._chunks) == nbChunks\n        assert node.globalStatus == status.name\n\n    def test_correctSizeUpdate(self, graphSavedOnDisk):\n        graph: Graph = graphSavedOnDisk\n        nodeA = graph.addNewNode(\"TestNode_SizeA\")\n        nodeB = graph.addNewNode(\"TestNode_SizeB\")\n        nodeA.output.connectTo(nodeB.nodeInput)\n        nodeC = graph.addNewNode(\"TestNode_SizeC\")\n        nodeB.output.connectTo(nodeC.nodeInput)\n        graph.save()\n        \n        # A\n        self.checkNodeSizeAndStatus(nodeA, 0, 0, Status.NONE)\n        nodeA.createChunks()\n        nodeA.process(inCurrentEnv=True)\n        self.checkNodeSizeAndStatus(nodeA, 2, 2, Status.SUCCESS)\n        # B\n        self.checkNodeSizeAndStatus(nodeB, 0, 1, Status.NONE)\n        nodeB.createChunks()\n        nodeB._updateNodeSize()\n        nodeB.process(inCurrentEnv=True)\n        self.checkNodeSizeAndStatus(nodeB, 2, 1, Status.SUCCESS)\n        # C\n        self.checkNodeSizeAndStatus(nodeC, 0, 0, Status.NONE)\n        nodeC.createChunks()\n        nodeC.process(inCurrentEnv=True)\n        self.checkNodeSizeAndStatus(nodeC, 2, 2, Status.SUCCESS)\n"
  },
  {
    "path": "tests/test_graph.py",
    "content": "from meshroom.core.exception import CyclicDependencyError\nfrom meshroom.core.graph import Graph\n\nimport pytest\n\n\ndef test_depth():\n    graph = Graph(\"Tests tasks depth\")\n\n    tA = graph.addNewNode(\"Ls\", input=\"/tmp\")\n    tB = graph.addNewNode(\"AppendText\", inputText=\"echo B\")\n    tC = graph.addNewNode(\"AppendText\", inputText=\"echo C\")\n\n    tA.output.connectTo(tB.input)\n    tB.output.connectTo(tC.input)\n\n    assert tA.depth == 0\n    assert tB.depth == 1\n    assert tC.depth == 2\n\n\ndef test_depth_diamond_graph():\n    graph = Graph(\"Tests tasks depth\")\n\n    tA = graph.addNewNode(\"Ls\", input=\"/tmp\")\n    tB = graph.addNewNode(\"AppendText\", inputText=\"echo B\")\n    tC = graph.addNewNode(\"AppendText\", inputText=\"echo C\")\n    tD = graph.addNewNode(\"AppendFiles\")\n\n    tA.output.connectTo(tB.input)\n    tA.output.connectTo(tC.input)\n    tB.output.connectTo(tD.input)\n    tC.output.connectTo(tD.input2)\n\n    assert tA.depth == 0\n    assert tB.depth == 1\n    assert tC.depth == 1\n    assert tD.depth == 2\n\n    nodes, edges = graph.dfsOnFinish()\n    assert len(nodes) == 4\n    assert nodes[0] == tA\n    assert nodes[-1] == tD\n    assert len(edges) == 4\n\n    nodes, edges = graph.dfsOnFinish(startNodes=[tD])\n    assert len(nodes) == 4\n    assert nodes[0] == tA\n    assert nodes[-1] == tD\n    assert len(edges) == 4\n\n    nodes, edges = graph.dfsOnFinish(startNodes=[tB])\n    assert len(nodes) == 2\n    assert nodes[0] == tA\n    assert nodes[-1] == tB\n    assert len(edges) == 1\n\n\ndef test_depth_diamond_graph2():\n    graph = Graph(\"Tests tasks depth\")\n\n    tA = graph.addNewNode(\"Ls\", input=\"/tmp\")\n    tB = graph.addNewNode(\"AppendText\", inputText=\"echo B\")\n    tC = graph.addNewNode(\"AppendText\", inputText=\"echo C\")\n    tD = graph.addNewNode(\"AppendText\", inputText=\"echo D\")\n    tE = graph.addNewNode(\"AppendFiles\")\n    #         C\n    #       /   \\\n    #  /---/---->\\\n    # A -> B ---> E\n    #      \\     /\n    #       \\   /\n    #         D\n    tA.output.connectTo(tB.input)\n    tB.output.connectTo(tC.input)\n    tB.output.connectTo(tD.input)\n    tA.output.connectTo(tE.input)\n    tB.output.connectTo(tE.input2)\n    tC.output.connectTo(tE.input3)\n    tD.output.connectTo(tE.input4)\n\n    assert tA.depth == 0\n    assert tB.depth == 1\n    assert tC.depth == 2\n    assert tD.depth == 2\n    assert tE.depth == 3\n\n    nodes, edges = graph.dfsOnFinish()\n    assert len(nodes) == 5\n    assert nodes[0] == tA\n    assert nodes[-1] == tE\n    assert len(edges) == 7\n\n    nodes, edges = graph.dfsOnFinish(startNodes=[tE])\n    assert len(nodes) == 5\n    assert nodes[0] == tA\n    assert nodes[-1] == tE\n    assert len(edges) == 7\n\n    nodes, edges = graph.dfsOnFinish(startNodes=[tD])\n    assert len(nodes) == 3\n    assert nodes[0] == tA\n    assert nodes[1] == tB\n    assert nodes[2] == tD\n    assert len(edges) == 2\n\n    nodes, edges = graph.dfsOnFinish(startNodes=[tB])\n    assert len(nodes) == 2\n    assert nodes[0] == tA\n    assert nodes[-1] == tB\n    assert len(edges) == 1\n\n\ndef test_transitive_reduction():\n    graph = Graph(\"Tests tasks depth\")\n\n    tA = graph.addNewNode(\"Ls\", input=\"/tmp\")\n    tB = graph.addNewNode(\"AppendText\", inputText=\"echo B\")\n    tC = graph.addNewNode(\"AppendText\", inputText=\"echo C\")\n    tD = graph.addNewNode(\"AppendText\", inputText=\"echo D\")\n    tE = graph.addNewNode(\"AppendFiles\")\n    #         C\n    #       /   \\\n    #  /---/---->\\\n    # A -> B ---> E\n    #      \\     /\n    #       \\   /\n    #         D\n    tA.output.connectTo(tE.input)\n    tA.output.connectTo(tB.input)\n    tB.output.connectTo(tC.input)\n    tB.output.connectTo(tD.input)\n    tB.output.connectTo(tE.input4)\n    tC.output.connectTo(tE.input3)\n    tD.output.connectTo(tE.input2)\n\n    flowEdges = graph.flowEdges()\n    flowEdgesRes = [(tB, tA),\n                    (tD, tB),\n                    (tC, tB),\n                    (tE, tD),\n                    (tE, tC),\n                    ]\n    assert set(flowEdgesRes) == set(flowEdges)\n\n    assert len(graph._nodesMinMaxDepths) == len(graph.nodes)\n    for node, (_, maxDepth) in graph._nodesMinMaxDepths.items():\n        assert node.depth == maxDepth\n\n\ndef test_graph_reverse_dfsOnDiscover():\n    graph = Graph(\"Test dfsOnDiscover(reverse=True)\")\n\n    #    ------------\\\n    #   /   ~ C - E - F\n    # A - B\n    #      ~ D\n\n    A = graph.addNewNode(\"Ls\", input=\"/tmp\")\n    B = graph.addNewNode(\"AppendText\", inputText=A.output)\n    C = graph.addNewNode(\"AppendText\", inputText=B.output)\n    D = graph.addNewNode(\"AppendText\", inputText=B.output)\n    E = graph.addNewNode(\"Ls\", input=C.output)\n    F = graph.addNewNode(\"AppendText\", input=A.output, inputText=E.output)\n\n    # Get all nodes from A (use set, order not guaranteed)\n    nodes = graph.dfsOnDiscover(startNodes=[A], reverse=True)[0]\n    assert set(nodes) == {A, B, D, C, E, F}\n    # Get all nodes from B\n    nodes = graph.dfsOnDiscover(startNodes=[B], reverse=True)[0]\n    assert set(nodes) == {B, D, C, E, F}\n    # Get all nodes of type AppendText from B\n    nodes = graph.dfsOnDiscover(startNodes=[B], filterTypes=[\"AppendText\"], reverse=True)[0]\n    assert set(nodes) == {B, D, C, F}\n    # Get all nodes from C (order guaranteed)\n    nodes = graph.dfsOnDiscover(startNodes=[C], reverse=True)[0]\n    assert nodes == [C, E, F]\n    # Get all nodes\n    nodes = graph.dfsOnDiscover(reverse=True)[0]\n    assert set(nodes) == {A, B, C, D, E, F}\n\n\ndef test_graph_dfsOnDiscover():\n    graph = Graph(\"Test dfsOnDiscover(reverse=False)\")\n\n    #    ------------\\\n    #   /   ~ C - E - F\n    # A - B\n    #      ~ D\n    #    G\n\n    G = graph.addNewNode(\"Ls\", input=\"/tmp\")\n    A = graph.addNewNode(\"Ls\", input=\"/tmp\")\n    B = graph.addNewNode(\"AppendText\", inputText=A.output)\n    C = graph.addNewNode(\"AppendText\", inputText=B.output)\n    D = graph.addNewNode(\"AppendText\", input=G.output, inputText=B.output)\n    E = graph.addNewNode(\"Ls\", input=C.output)\n    F = graph.addNewNode(\"AppendText\", input=A.output, inputText=E.output)\n\n    # Get all nodes from A (use set, order not guaranteed)\n    nodes = graph.dfsOnDiscover(startNodes=[A], reverse=False)[0]\n    assert set(nodes) == {A}\n    # Get all nodes from D\n    nodes = graph.dfsOnDiscover(startNodes=[D], reverse=False)[0]\n    assert set(nodes) == {A, B, D, G}\n    # Get all nodes from E\n    nodes = graph.dfsOnDiscover(startNodes=[E], reverse=False)[0]\n    assert set(nodes) == {A, B, C, E}\n    # Get all nodes from F\n    nodes = graph.dfsOnDiscover(startNodes=[F], reverse=False)[0]\n    assert set(nodes) == {A, B, C, E, F}\n    # Get all nodes of type AppendText from C\n    nodes = graph.dfsOnDiscover(startNodes=[C], filterTypes=[\"AppendText\"], reverse=False)[0]\n    assert set(nodes) == {B, C}\n    # Get all nodes from D (order guaranteed)\n    nodes = graph.dfsOnDiscover(startNodes=[D], longestPathFirst=True, reverse=False)[0]\n    assert nodes == [D, B, A, G]\n    # Get all nodes\n    nodes = graph.dfsOnDiscover(reverse=False)[0]\n    assert set(nodes) == {A, B, C, D, E, F, G}\n\n\ndef test_graph_nodes_sorting():\n    graph = Graph(\"\")\n\n    ls0 = graph.addNewNode(\"Ls\")\n    ls1 = graph.addNewNode(\"Ls\")\n    ls2 = graph.addNewNode(\"Ls\")\n\n    assert graph.nodesOfType(\"Ls\", sortedByIndex=True) == [ls0, ls1, ls2]\n\n    graph = Graph(\"\")\n    # 'Random' creation order (what happens when loading a file)\n    ls2 = graph.addNewNode(\"Ls\", name=\"Ls_2\")\n    ls0 = graph.addNewNode(\"Ls\", name=\"Ls_0\")\n    ls1 = graph.addNewNode(\"Ls\", name=\"Ls_1\")\n\n    assert graph.nodesOfType(\"Ls\", sortedByIndex=True) == [ls0, ls1, ls2]\n\n\ndef test_duplicate_nodes():\n    \"\"\"\n    Test nodes duplication.\n    \"\"\"\n\n    # n0 -- n1 -- n2\n    #   \\          \\\n    #    ---------- n3\n\n    g = Graph(\"\")\n    n0 = g.addNewNode(\"Ls\", input=\"/tmp\")\n    n1 = g.addNewNode(\"Ls\", input=n0.output)\n    n2 = g.addNewNode(\"Ls\", input=n1.output)\n    n3 = g.addNewNode(\"AppendFiles\", input=n1.output, input2=n2.output)\n\n    # Duplicate from n1\n    nodes_to_duplicate, _ = g.dfsOnDiscover(startNodes=[n1], reverse=True, dependenciesOnly=True)\n    nMap = g.duplicateNodes(srcNodes=nodes_to_duplicate)\n    for s, duplicated in nMap.items():\n        for d in duplicated:\n            assert s.nodeType == d.nodeType\n\n    # Check number of duplicated nodes and that every parent node has been duplicated once\n    assert len(nMap) == 3 and \\\n        all([len(nMap[i]) == 1 for i in nMap.keys()])\n\n    # Check connections\n    # Access directly index 0 because we know there is a single duplicate for each parent node\n    assert nMap[n1][0].input.inputLink == n0.output\n    assert nMap[n2][0].input.inputLink == nMap[n1][0].output\n    assert nMap[n3][0].input.inputLink == nMap[n1][0].output\n    assert nMap[n3][0].input2.inputLink == nMap[n2][0].output\n\n\ndef test_rename_nodes():\n    \"\"\"\n    Test renaming nodes.\n    \"\"\"\n\n    graph = Graph(\"\")\n    ls0 = graph.addNewNode(\"Ls\")\n    ls1 = graph.addNewNode(\"Ls\")\n    ls2 = graph.addNewNode(\"Ls\")\n\n    # Test with empty string\n    assert ls0.name == \"Ls_1\"\n    graph.renameNode(ls0, \"\")\n    assert ls0.name == \"Ls_1\"\n\n    # Rename\n    graph.renameNode(ls0, \"nodels\")\n    assert ls0.name == \"nodels\"\n    graph.renameNode(ls1, \"nodels\")\n    assert ls1.name == \"nodels_1\"\n    graph.renameNode(ls2, \"nodels\")\n    assert ls2.name == \"nodels_2\"\n    \n    # Check we cannot rename in locked mode\n    ls0.setLocked(True)\n    graph.renameNode(ls0, \"lockedLs\")\n    assert ls0.name == \"nodels\"\n"
  },
  {
    "path": "tests/test_graphIO.py",
    "content": "import json\nimport os\nfrom textwrap import dedent\nfrom pathlib import Path\n\nfrom meshroom.core import desc\nfrom meshroom.core.graph import Graph\nfrom meshroom.core.node import CompatibilityIssue\n\nfrom .utils import registeredNodeTypes, overrideNodeTypeVersion\n\n\nclass SimpleNode(desc.Node):\n    inputs = [\n        desc.File(name=\"input\", label=\"Input\", description=\"\", value=\"\"),\n    ]\n    outputs = [\n        desc.File(name=\"output\", label=\"Output\", description=\"\", value=\"\"),\n    ]\n\n\nclass NodeWithListAttributes(desc.Node):\n    inputs = [\n        desc.ListAttribute(\n            name=\"listInput\",\n            label=\"List Input\",\n            description=\"\",\n            elementDesc=desc.File(name=\"file\", label=\"File\", description=\"\", value=\"\"),\n            exposed=True,\n        ),\n        desc.GroupAttribute(\n            name=\"group\",\n            label=\"Group\",\n            description=\"\",\n            items=[\n                desc.ListAttribute(\n                    name=\"listInput\",\n                    label=\"List Input\",\n                    description=\"\",\n                    elementDesc=desc.File(name=\"file\", label=\"File\", description=\"\", value=\"\"),\n                    exposed=True,\n                ),\n            ],\n        ),\n    ]\n\n\ndef assertPathsAreEqual(pathA, pathB):\n    return Path(pathA).resolve().as_posix() == Path(pathB).resolve().as_posix()\n\n\ndef compareGraphsContent(graphA: Graph, graphB: Graph) -> bool:\n    \"\"\"Returns whether the content (node and deges) of two graphs are considered identical.\n\n    Similar nodes: nodes with the same name, type and compatibility status.\n    Similar edges: edges with the same source and destination attribute names.\n    \"\"\"\n\n    def _buildNodesSet(graph: Graph):\n        return set([(node.name, node.nodeType, node.isCompatibilityNode) for node in graph.nodes])\n\n    def _buildEdgesSet(graph: Graph):\n        return set([(edge.src.rootName, edge.dst.rootName) for edge in graph.edges])\n\n    nodesSetA, edgesSetA = _buildNodesSet(graphA), _buildEdgesSet(graphA)\n    nodesSetB, edgesSetB = _buildNodesSet(graphB), _buildEdgesSet(graphB)\n\n    return nodesSetA == nodesSetB and edgesSetA == edgesSetB\n\n\nclass TestImportGraphContent:\n    def test_importEmptyGraph(self):\n        graph = Graph(\"\")\n\n        otherGraph = Graph(\"\")\n        nodes = otherGraph.importGraphContent(graph)\n\n        assert len(nodes) == 0\n        assert len(graph.nodes) == 0\n\n    def test_importGraphWithSingleNode(self):\n        graph = Graph(\"\")\n\n        with registeredNodeTypes([SimpleNode]):\n            graph.addNewNode(SimpleNode.__name__)\n\n            otherGraph = Graph(\"\")\n            otherGraph.importGraphContent(graph)\n\n            assert compareGraphsContent(graph, otherGraph)\n\n    def test_importGraphWithSeveralNodes(self):\n        graph = Graph(\"\")\n\n        with registeredNodeTypes([SimpleNode]):\n            graph.addNewNode(SimpleNode.__name__)\n            graph.addNewNode(SimpleNode.__name__)\n\n            otherGraph = Graph(\"\")\n            otherGraph.importGraphContent(graph)\n\n            assert compareGraphsContent(graph, otherGraph)\n\n    def test_importingGraphWithNodesAndEdges(self):\n        graph = Graph(\"\")\n\n        with registeredNodeTypes([SimpleNode]):\n            nodeA_1 = graph.addNewNode(SimpleNode.__name__)\n            nodeA_2 = graph.addNewNode(SimpleNode.__name__)\n\n            nodeA_1.output.connectTo(nodeA_2.input)\n\n            otherGraph = Graph(\"\")\n            otherGraph.importGraphContent(graph)\n            assert compareGraphsContent(graph, otherGraph)\n\n    def test_edgeRemappingOnImportingGraphSeveralTimes(self):\n        graph = Graph(\"\")\n\n        with registeredNodeTypes([SimpleNode]):\n            nodeA_1 = graph.addNewNode(SimpleNode.__name__)\n            nodeA_2 = graph.addNewNode(SimpleNode.__name__)\n\n            nodeA_1.output.connectTo(nodeA_2.input)\n\n            otherGraph = Graph(\"\")\n            otherGraph.importGraphContent(graph)\n            otherGraph.importGraphContent(graph)\n\n    def test_edgeRemappingOnImportingGraphWithUnkownNodeTypesSeveralTimes(self):\n        graph = Graph(\"\")\n\n        with registeredNodeTypes([SimpleNode]):\n            nodeA_1 = graph.addNewNode(SimpleNode.__name__)\n            nodeA_2 = graph.addNewNode(SimpleNode.__name__)\n\n            nodeA_1.output.connectTo(nodeA_2.input)\n\n        otherGraph = Graph(\"\")\n        otherGraph.importGraphContent(graph)\n        otherGraph.importGraphContent(graph)\n\n        assert len(otherGraph.nodes) == 4\n        assert len(otherGraph.compatibilityNodes) == 4\n        assert len(otherGraph.edges) == 2\n\n    def test_importGraphWithUnknownNodeTypesCreatesCompatibilityNodes(self):\n        graph = Graph(\"\")\n\n        with registeredNodeTypes([SimpleNode]):\n            graph.addNewNode(SimpleNode.__name__)\n\n        otherGraph = Graph(\"\")\n        importedNode = otherGraph.importGraphContent(graph)\n\n        assert len(importedNode) == 1\n        assert importedNode[0].isCompatibilityNode\n\n    def test_importGraphContentInPlace(self):\n        graph = Graph(\"\")\n\n        with registeredNodeTypes([SimpleNode]):\n            nodeA_1 = graph.addNewNode(SimpleNode.__name__)\n            nodeA_2 = graph.addNewNode(SimpleNode.__name__)\n\n            nodeA_1.output.connectTo(nodeA_2.input)\n\n            graph.importGraphContent(graph)\n\n            assert len(graph.nodes) == 4\n\n    def test_importGraphContentFromFile(self, graphSavedOnDisk):\n        graph: Graph = graphSavedOnDisk\n\n        with registeredNodeTypes([SimpleNode]):\n            nodeA_1 = graph.addNewNode(SimpleNode.__name__)\n            nodeA_2 = graph.addNewNode(SimpleNode.__name__)\n\n            nodeA_1.output.connectTo(nodeA_2.input)\n            graph.save()\n\n            otherGraph = Graph(\"\")\n            nodes = otherGraph.importGraphContentFromFile(graph.filepath)\n\n            assert len(nodes) == 2\n\n            assert compareGraphsContent(graph, otherGraph)\n\n    def test_importGraphContentFromFileWithCompatibilityNodes(self, graphSavedOnDisk):\n        graph: Graph = graphSavedOnDisk\n\n        with registeredNodeTypes([SimpleNode]):\n            nodeA_1 = graph.addNewNode(SimpleNode.__name__)\n            nodeA_2 = graph.addNewNode(SimpleNode.__name__)\n\n            nodeA_1.output.connectTo(nodeA_2.input)\n            graph.save()\n\n        otherGraph = Graph(\"\")\n        nodes = otherGraph.importGraphContentFromFile(graph.filepath)\n\n        assert len(nodes) == 2\n        assert len(otherGraph.compatibilityNodes) == 2\n        assert not compareGraphsContent(graph, otherGraph)\n\n    def test_importingDifferentNodeVersionCreatesCompatibilityNodes(self, graphSavedOnDisk):\n        graph: Graph = graphSavedOnDisk\n\n        with registeredNodeTypes([SimpleNode]):\n            with overrideNodeTypeVersion(SimpleNode, \"1.0\"):\n                node = graph.addNewNode(SimpleNode.__name__)\n                graph.save()\n\n            with overrideNodeTypeVersion(SimpleNode, \"2.0\"):\n                otherGraph = Graph(\"\")\n                nodes = otherGraph.importGraphContentFromFile(graph.filepath)\n\n        assert len(nodes) == 1\n        assert len(otherGraph.compatibilityNodes) == 1\n        assert otherGraph.node(node.name).issue is CompatibilityIssue.VersionConflict\n\n\nclass TestGraphSave:\n    def test_generateNextPath(self, graphSavedOnDisk):\n        graph: Graph = graphSavedOnDisk\n        root = os.path.dirname(graph._filepath)\n        # Files with no version number (e.g., \"scene.mg\" -> \"scene1.mg\")\n        graph._filepath = os.path.join(root, \"scene.mg\")\n        assertPathsAreEqual(graph._generateNextPath(), os.path.join(root, \"scene1.mg\"))\n        # Files with existing version numbers (e.g., \"scene1.mg\" -> \"scene2.mg\")\n        graph._filepath = os.path.join(root, \"scene_1.mg\")\n        assertPathsAreEqual(graph._generateNextPath(), os.path.join(root, \"scene_2.mg\"))\n        # Edge cases like filenames that are purely numeric (e.g., \"123.mg\")\n        # Also test that the padding is kept (\"001\" -> \"002\" and not \"2\")\n        graph._filepath = os.path.join(root, \"0123.mg\")\n        assertPathsAreEqual(graph._generateNextPath(), os.path.join(root, \"0124.mg\"))\n        graph._filepath = os.path.join(root, \"scene_001.mg\")\n        assertPathsAreEqual(graph._generateNextPath(), os.path.join(root, \"scene_002.mg\"))\n        # Files where the next version already exists (e.g., \"scene1.mg\" when \"scene2.mg\" exists -> \"scene3.mg\")\n        graph._filepath = os.path.join(root, \"scene1.mg\")\n        open(os.path.join(root, \"scene2.mg\"), 'a').close()\n        assertPathsAreEqual(graph._generateNextPath(), os.path.join(root, \"scene3.mg\"))\n\n    def test_saveAsNewVersion(self, tmp_path):\n        graph = Graph(\"\")\n        with registeredNodeTypes([SimpleNode]):\n            # Create scene\n            nodeA = graph.addNewNode(SimpleNode.__name__)\n            scenePath = os.path.join(tmp_path, \"scene.mg\")\n            graph._filepath = scenePath\n            graph.save()\n            assert os.path.exists(scenePath)\n            # Modify scene\n            nodeB = graph.addNewNode(SimpleNode.__name__)\n            nodeA.output.connectTo(nodeB.input)\n            graph.saveAsNewVersion()\n            newScenePath = os.path.join(tmp_path, \"scene1.mg\")\n            assert os.path.exists(newScenePath)\n\n\nclass TestGraphPartialSerialization:\n    def test_emptyGraph(self):\n        graph = Graph(\"\")\n        serializedGraph = graph.serializePartial([])\n\n        otherGraph = Graph(\"\")\n        otherGraph._deserialize(serializedGraph)\n        assert compareGraphsContent(graph, otherGraph)\n\n    def test_serializeAllNodesIsSimilarToStandardSerialization(self):\n        graph = Graph(\"\")\n\n        with registeredNodeTypes([SimpleNode]):\n            nodeA = graph.addNewNode(SimpleNode.__name__)\n            nodeB = graph.addNewNode(SimpleNode.__name__)\n\n            nodeA.output.connectTo(nodeB.input)\n\n            partialSerializedGraph = graph.serializePartial([nodeA, nodeB])\n            standardSerializedGraph = graph.serialize()\n\n            graphA = Graph(\"\")\n            graphA._deserialize(partialSerializedGraph)\n\n            graphB = Graph(\"\")\n            graphB._deserialize(standardSerializedGraph)\n\n            assert compareGraphsContent(graph, graphA)\n            assert compareGraphsContent(graphA, graphB)\n\n    def test_listAttributeToListAttributeConnectionIsSerialized(self):\n        graph = Graph(\"\")\n\n        with registeredNodeTypes([NodeWithListAttributes]):\n            nodeA = graph.addNewNode(NodeWithListAttributes.__name__)\n            nodeB = graph.addNewNode(NodeWithListAttributes.__name__)\n\n            nodeA.listInput.connectTo(nodeB.listInput)\n\n            otherGraph = Graph(\"\")\n            otherGraph._deserialize(graph.serializePartial([nodeA, nodeB]))\n\n            assert otherGraph.node(nodeB.name).listInput.inputLink == \\\n                otherGraph.node(nodeA.name).listInput\n\n    def test_singleNodeWithInputConnectionFromNonSerializedNodeRemovesEdge(self):\n        graph = Graph(\"\")\n\n        with registeredNodeTypes([SimpleNode]):\n            nodeA = graph.addNewNode(SimpleNode.__name__)\n            nodeB = graph.addNewNode(SimpleNode.__name__)\n\n            nodeA.output.connectTo(nodeB.input)\n\n            serializedGraph = graph.serializePartial([nodeB])\n\n            otherGraph = Graph(\"\")\n            otherGraph._deserialize(serializedGraph)\n\n            assert len(otherGraph.compatibilityNodes) == 0\n            assert len(otherGraph.nodes) == 1\n            assert len(otherGraph.edges) == 0\n\n    def test_serializeSingleNodeWithInputConnectionToListAttributeRemovesListEntry(self):\n        graph = Graph(\"\")\n\n        with registeredNodeTypes([SimpleNode, NodeWithListAttributes]):\n            nodeA = graph.addNewNode(SimpleNode.__name__)\n            nodeB = graph.addNewNode(NodeWithListAttributes.__name__)\n\n            nodeB.listInput.append(\"\")\n            nodeA.output.connectTo(nodeB.listInput.at(0))\n\n            otherGraph = Graph(\"\")\n            otherGraph._deserialize(graph.serializePartial([nodeB]))\n\n            assert len(otherGraph.node(nodeB.name).listInput) == 0\n\n    def test_serializeSingleNodeWithInputConnectionToNestedListAttributeRemovesListEntry(self):\n        graph = Graph(\"\")\n\n        with registeredNodeTypes([SimpleNode, NodeWithListAttributes]):\n            nodeA = graph.addNewNode(SimpleNode.__name__)\n            nodeB = graph.addNewNode(NodeWithListAttributes.__name__)\n\n            nodeB.group.listInput.append(\"\")\n            nodeA.output.connectTo(nodeB.group.listInput.at(0))\n\n            otherGraph = Graph(\"\")\n            otherGraph._deserialize(graph.serializePartial([nodeB]))\n\n            assert len(otherGraph.node(nodeB.name).group.listInput) == 0\n\n\nclass TestGraphCopy:\n    def test_graphCopyIsIdenticalToOriginalGraph(self):\n        graph = Graph(\"\")\n\n        with registeredNodeTypes([SimpleNode]):\n            nodeA = graph.addNewNode(SimpleNode.__name__)\n            nodeB = graph.addNewNode(SimpleNode.__name__)\n\n            nodeA.output.connectTo(nodeB.input)\n\n            graphCopy = graph.copy()\n            assert compareGraphsContent(graph, graphCopy)\n\n    def test_graphCopyWithUnknownNodeTypesDiffersFromOriginalGraph(self):\n        graph = Graph(\"\")\n\n        with registeredNodeTypes([SimpleNode]):\n            nodeA = graph.addNewNode(SimpleNode.__name__)\n            nodeB = graph.addNewNode(SimpleNode.__name__)\n\n            nodeA.output.connectTo(nodeB.input)\n\n        graphCopy = graph.copy()\n        assert not compareGraphsContent(graph, graphCopy)\n\n\nclass TestImportGraphContentFromMinimalGraphData:\n    def test_nodeWithoutVersionInfoIsUpgraded(self):\n        graph = Graph(\"\")\n\n        with (\n            registeredNodeTypes([SimpleNode]),\n            overrideNodeTypeVersion(SimpleNode, \"2.0\"),\n        ):\n            sampleGraphContent = dedent(\"\"\"\n            {\n                \"SimpleNode_1\": { \"nodeType\": \"SimpleNode\" }\n            }\n            \"\"\")\n            graph._deserialize(json.loads(sampleGraphContent))\n\n            assert len(graph.nodes) == 1\n            assert len(graph.compatibilityNodes) == 0\n\n    def test_connectionsToMissingNodesAreDiscarded(self):\n        graph = Graph(\"\")\n\n        with registeredNodeTypes([SimpleNode]):\n            sampleGraphContent = dedent(\"\"\"\n            {\n                \"SimpleNode_1\": {\n                    \"nodeType\": \"SimpleNode\", \"inputs\": { \"input\": \"{NotSerializedNode.output}\" }\n                }\n            }\n            \"\"\")\n            graph._deserialize(json.loads(sampleGraphContent))\n"
  },
  {
    "path": "tests/test_groupAttributes.py",
    "content": "#!/usr/bin/env python\n# coding:utf-8\n\nimport os\nimport tempfile\n\nfrom meshroom.core.graph import Graph, loadGraph\nfrom meshroom.core.node import CompatibilityNode\nfrom meshroom.core.attribute import GroupAttribute\n\n# 1 int, 1 exclusive choice param, 1 choice param, 1 bool, 1 group, 1 float nested in the group, 2 lists\nGROUPATTRIBUTES_FIRSTGROUP_NB_CHILDREN = 8\n\nGROUPATTRIBUTES_FIRSTGROUP_NESTED_NB_CHILDREN = 1  # 1 float\nGROUPATTRIBUTES_OUTPUTGROUP_NB_CHILDREN = 1  # 1 bool\nGROUPATTRIBUTES_FIRSTGROUP_DEPTHS = [1, 1, 1, 1, 1, 2, 1, 1]\n\n\nclass TestGroupAttributes:\n    def test_saveLoadGroupDirectConnections(self):\n        \"\"\"\n        Ensure that connecting GroupAttributes does not cause their nodes to have CompatibilityIssues\n        when re-opening them.\n        \"\"\"\n        graph = Graph(\"Connections between GroupAttributes\")\n\n        # Create two \"GroupAttributes\" nodes with their default parameters\n        nodeA = graph.addNewNode(\"GroupAttributes\")\n        nodeB = graph.addNewNode(\"GroupAttributes\")\n\n        # Connect attributes within groups at different depth levels\n        nodeA.firstGroup.connectTo(nodeB.firstGroup)\n\n        # Save the graph in a file\n        graphFile = os.path.join(tempfile.mkdtemp(), \"test_io_group_connections.mg\")\n        graph.save(graphFile)\n\n        # Reload the graph\n        graph = loadGraph(graphFile)\n\n        assert graph.node(\"GroupAttributes_2\").firstGroup.inputLink == graph.node(\"GroupAttributes_1\").firstGroup\n\n\n    def test_saveLoadGroupConnections(self):\n        \"\"\"\n        Ensure that connecting attributes that are part of GroupAttributes does not cause their nodes to have\n        CompatibilityIssues when re-opening them.\n        \"\"\"\n        graph = Graph(\"Connections between subattributes in GroupAttributes\")\n\n        # Create two \"GroupAttributes\" nodes with their default parameters\n        nodeA = graph.addNewNode(\"GroupAttributes\")\n        nodeB = graph.addNewNode(\"GroupAttributes\")\n\n        # Connect attributes within groups at different depth levels\n        nodeA.firstGroup.firstGroupIntA.connectTo(nodeB.firstGroup.firstGroupIntA)\n        nodeA.firstGroup.nestedGroup.nestedGroupFloat.connectTo(\n            nodeB.firstGroup.nestedGroup.nestedGroupFloat)\n\n        # Save the graph in a file\n        graphFile = os.path.join(tempfile.mkdtemp(), \"test_io_group_connections.mg\")\n        graph.save(graphFile)\n\n        # Reload the graph\n        graph = loadGraph(graphFile)\n\n        # Ensure the nodes are not CompatibilityNodes\n        for node in graph.nodes:\n            assert not isinstance(node, CompatibilityNode)\n\n\n    def test_groupAttributesFlatChildren(self):\n        \"\"\"\n        Check that the list of static flat children is correct, even with list elements.\n        \"\"\"\n        graph = Graph(\"Children of GroupAttributes\")\n\n        # Create two \"GroupAttributes\" nodes with their default parameters\n        node = graph.addNewNode(\"GroupAttributes\")\n\n        intAttr = node.attribute(\"exposedInt\")\n        assert not isinstance(intAttr, GroupAttribute)\n        assert len(intAttr.flatStaticChildren) == 0  # Not a Group, cannot have any child\n\n        inputGroup = node.attribute(\"firstGroup\")\n        assert isinstance(inputGroup, GroupAttribute)\n        assert len(inputGroup.flatStaticChildren) == GROUPATTRIBUTES_FIRSTGROUP_NB_CHILDREN\n\n        # Add an element to a list within the group and check the number of children has not changed\n        groupedList = node.attribute(\"firstGroup.singleGroupedList\")\n        groupedList.insert(0, 30)\n        assert len(groupedList.flatStaticChildren) == 0  # Not a Group, elements are not counted as children\n        assert len(inputGroup.flatStaticChildren) == GROUPATTRIBUTES_FIRSTGROUP_NB_CHILDREN\n\n        nestedGroup = node.attribute(\"firstGroup.nestedGroup\")\n        assert isinstance(nestedGroup, GroupAttribute)\n        assert len(nestedGroup.flatStaticChildren) == GROUPATTRIBUTES_FIRSTGROUP_NESTED_NB_CHILDREN\n\n        outputGroup = node.attribute(\"outputGroup\")\n        assert isinstance(outputGroup, GroupAttribute)\n        assert len(outputGroup.flatStaticChildren) == GROUPATTRIBUTES_OUTPUTGROUP_NB_CHILDREN\n\n\n    def test_groupAttributesDepthLevels(self):\n        \"\"\"\n        Check that the depth level of children attributes is correctly set.\n        \"\"\"\n        graph = Graph(\"Children of GroupAttributes\")\n\n        # Create two \"GroupAttributes\" nodes with their default parameters\n        node = graph.addNewNode(\"GroupAttributes\")\n        inputGroup = node.attribute(\"firstGroup\")\n        assert isinstance(inputGroup, GroupAttribute)\n        assert inputGroup.depth == 0  # Root level\n\n        cnt = 0\n        for child in inputGroup.flatStaticChildren:\n            assert child.depth == GROUPATTRIBUTES_FIRSTGROUP_DEPTHS[cnt]\n            cnt = cnt + 1\n\n        outputGroup = node.attribute(\"outputGroup\")\n        assert isinstance(outputGroup, GroupAttribute)\n        assert outputGroup.depth == 0\n        for child in outputGroup.flatStaticChildren:  # Single element in the group\n            assert child.depth == 1\n\n\n        intAttr = node.attribute(\"exposedInt\")\n        assert not isinstance(intAttr, GroupAttribute)\n        assert intAttr.depth == 0\n\n\n    def test_groupAttributesWithMatchingStructure(self):\n        \"\"\"\n        Check that two different GroupAttributes can be connected if they have a matching structure.\n        \"\"\"\n        # Given\n        graph = Graph()\n        nestedPosition = graph.addNewNode(\"NestedPosition\")\n        nestedColor = graph.addNewNode(\"NestedColor\")\n\n        # When\n        acceptedConnection = nestedPosition.xyz.validateIncomingConnection(nestedColor.rgb)\n\n        # Then\n        assert acceptedConnection\n\n\n    def test_groupAttributesWithDifferentStructures(self):\n        \"\"\"\n        Check that two different GroupAttributes cannot be connected if they have different structures.\n        \"\"\"\n        # Given\n        graph = Graph()\n        nestedPosition = graph.addNewNode(\"NestedPosition\")\n        nestedTest = graph.addNewNode(\"NestedTest\")\n\n        # When\n        acceptedConnection = nestedPosition.xyz.validateIncomingConnection(nestedTest.xyz)\n\n        # Then\n        assert not acceptedConnection\n\n\n    def test_connectGroupsWithSubAttributes(self):\n        \"\"\"\n        Check that when a group is connected to another group, all the sub-attributes are connected\n        together automatically.\n        \"\"\"\n        # Given\n        graph = Graph()\n\n        nestedColor = graph.addNewNode(\"NestedColor\")\n        nestedPosition = graph.addNewNode(\"NestedPosition\")\n\n        assert not nestedPosition.xyz.isLink\n        assert not nestedPosition.xyz.x.isLink\n        assert not nestedPosition.xyz.y.isLink\n        assert not nestedPosition.xyz.z.isLink\n        assert not nestedPosition.xyz.test.isLink\n        assert not nestedPosition.xyz.test.x.isLink\n        assert not nestedPosition.xyz.test.y.isLink\n        assert not nestedPosition.xyz.test.z.isLink\n\n        # When\n        nestedColor.rgb.connectTo(nestedPosition.xyz)\n\n        # Then\n        assert nestedPosition.xyz.isLink and \\\n            nestedPosition.xyz.inputLink.asLinkExpr() == nestedColor.rgb.asLinkExpr()\n        assert nestedPosition.xyz.x.isLink and \\\n            nestedPosition.xyz.x.inputLink.asLinkExpr() == nestedColor.rgb.r.asLinkExpr()\n        assert nestedPosition.xyz.y.isLink and \\\n            nestedPosition.xyz.y.inputLink.asLinkExpr() == nestedColor.rgb.g.asLinkExpr()\n        assert nestedPosition.xyz.z.isLink and \\\n            nestedPosition.xyz.z.inputLink.asLinkExpr() == nestedColor.rgb.b.asLinkExpr()\n        assert nestedPosition.xyz.test.isLink and \\\n            nestedPosition.xyz.test.inputLink.asLinkExpr() == nestedColor.rgb.test.asLinkExpr()\n        assert nestedPosition.xyz.test.x.isLink and \\\n            nestedPosition.xyz.test.x.inputLink.asLinkExpr() == nestedColor.rgb.test.r.asLinkExpr()\n        assert nestedPosition.xyz.test.y.isLink and \\\n            nestedPosition.xyz.test.y.inputLink.asLinkExpr() == nestedColor.rgb.test.g.asLinkExpr()\n        assert nestedPosition.xyz.test.z.isLink and \\\n            nestedPosition.xyz.test.z.inputLink.asLinkExpr() == nestedColor.rgb.test.b.asLinkExpr()\n\n        # Save the graph in a file\n        graphFile = os.path.join(tempfile.mkdtemp(), \"test_io_group_connections.mg\")\n        graph.save(graphFile)\n\n        # Reload the graph\n        graph = loadGraph(graphFile)\n        nestedPosition = graph.node(\"NestedPosition_1\")\n        nestedColor = graph.node(\"NestedColor_1\")\n\n        assert nestedPosition.xyz.isLink and \\\n            nestedPosition.xyz.inputLink.asLinkExpr() == nestedColor.rgb.asLinkExpr()\n        assert nestedPosition.xyz.x.isLink and \\\n            nestedPosition.xyz.x.inputLink.asLinkExpr() == nestedColor.rgb.r.asLinkExpr()\n        assert nestedPosition.xyz.y.isLink and \\\n            nestedPosition.xyz.y.inputLink.asLinkExpr() == nestedColor.rgb.g.asLinkExpr()\n        assert nestedPosition.xyz.z.isLink and \\\n            nestedPosition.xyz.z.inputLink.asLinkExpr() == nestedColor.rgb.b.asLinkExpr()\n        assert nestedPosition.xyz.test.isLink and \\\n            nestedPosition.xyz.test.inputLink.asLinkExpr() == nestedColor.rgb.test.asLinkExpr()\n        assert nestedPosition.xyz.test.x.isLink and \\\n            nestedPosition.xyz.test.x.inputLink.asLinkExpr() == nestedColor.rgb.test.r.asLinkExpr()\n        assert nestedPosition.xyz.test.y.isLink and \\\n            nestedPosition.xyz.test.y.inputLink.asLinkExpr() == nestedColor.rgb.test.g.asLinkExpr()\n        assert nestedPosition.xyz.test.z.isLink and \\\n            nestedPosition.xyz.test.z.inputLink.asLinkExpr() == nestedColor.rgb.test.b.asLinkExpr()\n\n\n    def test_connectSubAttributes(self):\n        \"\"\"\n        After a group has been connected to another group, connecting individually a sub-attribute\n        should disconnect the group itself.\n        \"\"\"\n        # Given\n        graph = Graph()\n\n        nestedColor = graph.addNewNode(\"NestedColor\")\n        nestedPosition = graph.addNewNode(\"NestedPosition\")\n\n        nestedColor.rgb.connectTo(nestedPosition.xyz)\n\n        assert nestedPosition.xyz.isLink and \\\n            nestedPosition.xyz.inputLink.asLinkExpr() == nestedColor.rgb.asLinkExpr()\n        assert nestedPosition.xyz.x.isLink and \\\n            nestedPosition.xyz.x.inputLink.asLinkExpr() == nestedColor.rgb.r.asLinkExpr()\n        assert nestedPosition.xyz.y.isLink and \\\n            nestedPosition.xyz.y.inputLink.asLinkExpr() == nestedColor.rgb.g.asLinkExpr()\n        assert nestedPosition.xyz.z.isLink and \\\n            nestedPosition.xyz.z.inputLink.asLinkExpr() == nestedColor.rgb.b.asLinkExpr()\n        assert nestedPosition.xyz.test.isLink and \\\n            nestedPosition.xyz.test.inputLink.asLinkExpr() == nestedColor.rgb.test.asLinkExpr()\n        assert nestedPosition.xyz.test.x.isLink and \\\n            nestedPosition.xyz.test.x.inputLink.asLinkExpr() == nestedColor.rgb.test.r.asLinkExpr()\n        assert nestedPosition.xyz.test.y.isLink and \\\n            nestedPosition.xyz.test.y.inputLink.asLinkExpr() == nestedColor.rgb.test.g.asLinkExpr()\n        assert nestedPosition.xyz.test.z.isLink and \\\n            nestedPosition.xyz.test.z.inputLink.asLinkExpr() == nestedColor.rgb.test.b.asLinkExpr()\n\n        # When\n        r = nestedColor.rgb.r\n        z = nestedPosition.xyz.test.z\n        r.connectTo(z)\n\n        # Then\n        assert not nestedPosition.xyz.isLink  # Disconnected because sub GroupAttribute has been disconnected\n        assert nestedPosition.xyz.x.isLink and \\\n            nestedPosition.xyz.x.inputLink.asLinkExpr() == nestedColor.rgb.r.asLinkExpr()\n        assert nestedPosition.xyz.y.isLink and \\\n            nestedPosition.xyz.y.inputLink.asLinkExpr() == nestedColor.rgb.g.asLinkExpr()\n        assert nestedPosition.xyz.z.isLink and \\\n            nestedPosition.xyz.z.inputLink.asLinkExpr() == nestedColor.rgb.b.asLinkExpr()\n        assert not nestedPosition.xyz.test.isLink  # Disconnected because nestedPosition.xyz.test.z has been reconnected\n        assert nestedPosition.xyz.test.x.isLink and \\\n            nestedPosition.xyz.test.x.inputLink.asLinkExpr() == nestedColor.rgb.test.r.asLinkExpr()\n        assert nestedPosition.xyz.test.y.isLink and \\\n            nestedPosition.xyz.test.y.inputLink.asLinkExpr() == nestedColor.rgb.test.g.asLinkExpr()\n        assert nestedPosition.xyz.test.z.isLink and \\\n            nestedPosition.xyz.test.z.inputLink.asLinkExpr() == r.asLinkExpr() == nestedColor.rgb.r.asLinkExpr()\n\n\n    def test_connectGroupSubAttributesByValue(self):\n        \"\"\"\n        Check that sub-attributes are connected by value and not by reference. When connected to another sub-attribute\n        through a group connection, a given sub-attribute should have an address that differs from the incoming sub-attribute.\n        \"\"\"\n        graph = Graph()\n        groupA = graph.addNewNode(\"GroupAttributes\")\n        groupB = graph.addNewNode(\"GroupAttributes\")\n\n        groupA.firstGroup.firstGroupIntA.value = 1234\n        assert groupA.firstGroup.firstGroupIntA.value != groupB.firstGroup.firstGroupIntA.value\n\n        # Connect the groups\n        groupA.firstGroup.connectTo(groupB.firstGroup)\n\n        subAttributeA = groupA.firstGroup.firstGroupIntA\n        subAttributeB = groupB.firstGroup.firstGroupIntA\n        assert subAttributeA != subAttributeB\n        assert subAttributeB.isLink\n        assert subAttributeA.fullName != subAttributeB.fullName\n        assert groupA.firstGroup.firstGroupIntA.value == groupB.firstGroup.firstGroupIntA.value == 1234\n"
  },
  {
    "path": "tests/test_invalidation.py",
    "content": "#!/usr/bin/env python\n# coding:utf-8\nfrom meshroom.core.graph import Graph\nfrom meshroom.core import desc\n\nfrom .utils import registerNodeDesc, unregisterNodeDesc\n\n\nclass SampleNode(desc.Node):\n    \"\"\" Sample Node for unit testing \"\"\"\n    inputs = [\n        desc.File(name=\"input\", label=\"Input\", description=\"\", value=\"\",),\n        desc.StringParam(name=\"paramA\", label=\"ParamA\",\n                         description=\"\", value=\"\",\n                         invalidate=False)  # No impact on UID\n    ]\n    outputs = [\n        desc.File(name='output', label='Output', description='', value=\"{nodeCacheFolder}\")\n    ]\n\n\ndef test_output_invalidation():\n    registerNodeDesc(SampleNode)  # Register standalone NodePlugin\n    graph = Graph(\"\")\n    n1 = graph.addNewNode(\"SampleNode\", input=\"/tmp\")\n    n2 = graph.addNewNode(\"SampleNode\")\n    n3 = graph.addNewNode(\"SampleNode\")\n\n    n1.output.connectTo(n2.input)\n    n1.output.connectTo(n3.input)\n\n    # N1.output ----- N2.input\n    #                \\\n    #                 N3.input\n\n    # Compare UIDs of similar attributes on different nodes\n    n2inputUid = n2.input.uid()\n    n3inputUid = n3.input.uid()\n    assert n3inputUid == n2inputUid      # => UIDs are equal\n\n    # Change a parameter outside UID\n    n1.paramA.value = 'a'\n    assert n2.input.uid() == n2inputUid  # => same UID as before\n\n    # Change a parameter impacting UID\n    n1.input.value = \"/a/path\"\n    assert n2.input.uid() != n2inputUid      # => UID has changed\n    assert n2.input.uid() == n3.input.uid()  # => UIDs on both node are still equal\n    unregisterNodeDesc(SampleNode)\n\ndef test_inputLinkInvalidation():\n    \"\"\"\n    Input links should not change the invalidation.\n    \"\"\"\n    registerNodeDesc(SampleNode)  # Register standalone NodePlugin\n    graph = Graph(\"\")\n    n1 = graph.addNewNode(\"SampleNode\")\n    n2 = graph.addNewNode(\"SampleNode\")\n\n    n1.input.connectTo(n2.input)\n    assert n1.input.uid() == n2.input.uid()\n    assert n1.output.value == n2.output.value\n    unregisterNodeDesc(SampleNode)\n"
  },
  {
    "path": "tests/test_listAttribute.py",
    "content": "from meshroom.core import desc\nfrom meshroom.core.graph import Graph\n\nfrom .utils import registerNodeDesc, unregisterNodeDesc\n\n\nclass NodeWithListAttribute(desc.Node):\n    inputs = [\n        desc.ListAttribute(\n            name=\"listInput\",\n            label=\"List Input\",\n            description=\"ListAttribute of StringParams.\",\n            elementDesc=desc.StringParam(name=\"value\", label=\"Value\", description=\"\", value=\"\"),\n        )\n    ]\n\n\nclass TestListAttribute:\n\n    @classmethod\n    def setup_class(cls):\n        registerNodeDesc(NodeWithListAttribute)\n\n    @classmethod\n    def teardown_class(cls):\n        unregisterNodeDesc(NodeWithListAttribute)\n\n    def test_lengthUsesLinkParam(self):\n        graph = Graph(\"\")\n\n        nodeA = graph.addNewNode(NodeWithListAttribute.__name__)\n        nodeB = graph.addNewNode(NodeWithListAttribute.__name__)\n\n        nodeA.listInput.connectTo(nodeB.listInput)\n\n        nodeA.listInput.append(\"test\")\n\n        assert len(nodeB.listInput) == 1\n\n    def test_iterationUsesLinkParam(self):\n        graph = Graph(\"\")\n\n        nodeA = graph.addNewNode(NodeWithListAttribute.__name__)\n        nodeB = graph.addNewNode(NodeWithListAttribute.__name__)\n\n        nodeA.listInput.connectTo(nodeB.listInput)\n\n        nodeA.listInput.extend([\"A\", \"B\", \"C\"])\n\n        for value in nodeB.listInput:\n            assert value.node == nodeA\n\n    def test_elementAccessUsesLinkParam(self):\n        graph = Graph(\"\")\n\n        nodeA = graph.addNewNode(NodeWithListAttribute.__name__)\n        nodeB = graph.addNewNode(NodeWithListAttribute.__name__)\n\n        nodeA.listInput.connectTo(nodeB.listInput)\n\n        nodeA.listInput.extend([\"A\", \"B\", \"C\"])\n\n        assert nodeB.listInput.at(0).node == nodeA\n        assert nodeB.listInput.index(nodeB.listInput.at(0)) == 0\n"
  },
  {
    "path": "tests/test_model.py",
    "content": "import pytest\n\nfrom PySide6.QtCore import QObject, Property\n\nfrom meshroom.common.core import CoreDictModel\nfrom meshroom.common.qt import QObjectListModel, QTypedObjectListModel\n\n\nclass DummyNode(QObject):\n\n    def __init__(self, name=\"\", parent=None):\n        super(DummyNode, self).__init__(parent)\n        self._name = name\n\n    def getName(self):\n        return self._name\n\n    name = Property(str, getName)\n\n\ndef test_DictModel_add_remove():\n    for DictModel in (CoreDictModel, QObjectListModel):\n        m = DictModel(keyAttrName='name')\n        node = DummyNode(\"DummyNode_1\")\n        m.add(node)\n        assert len(m) == 1\n        assert len(m.keys()) == 1\n        assert len(m.values()) == 1\n        assert m.get(\"DummyNode_1\") == node\n\n        assert m.get(\"something\") is None\n        with pytest.raises(KeyError):\n            m.getr(\"something\")\n\n        m.pop(\"DummyNode_1\")\n        assert len(m) == 0\n        assert len(m.keys()) == 0\n        assert len(m.values()) == 0\n\n\ndef test_listModel_typed_add():\n    m = QTypedObjectListModel(T=DummyNode)\n    assert m.roleForName('name') != -1\n\n    node = DummyNode(\"DummyNode_1\")\n    m.add(node)\n    assert m.data(m.index(0), m.roleForName('name')) == \"DummyNode_1\"\n\n    obj = QObject()\n    with pytest.raises(TypeError):\n        m.add(obj)\n"
  },
  {
    "path": "tests/test_nodeAttributeChangedCallback.py",
    "content": "# coding:utf-8\n\nfrom meshroom.core.graph import Graph, loadGraph, executeGraph\nfrom meshroom.core import desc\nfrom meshroom.core.node import Node\n\nfrom .utils import registerNodeDesc, unregisterNodeDesc\n\n\nclass NodeWithAttributeChangedCallback(desc.BaseNode):\n    \"\"\"\n    A Node containing an input Attribute with an 'on{Attribute}Changed' method,\n    called whenever the value of this attribute is changed explicitly.\n    \"\"\"\n\n    inputs = [\n        desc.IntParam(\n            name=\"input\",\n            label=\"Input\",\n            description=\"Attribute with a value changed callback (onInputChanged)\",\n            value=0,\n            range=None,\n        ),\n        desc.IntParam(\n            name=\"affectedInput\",\n            label=\"Affected Input\",\n            description=\"Updated to input.value * 2 whenever 'input' is explicitly modified\",\n            value=0,\n            range=None,\n        ),\n    ]\n\n    def onInputChanged(self, instance: Node):\n        instance.affectedInput.value = instance.input.value * 2\n\n    def processChunk(self, chunk):\n        pass  # No-op.\n\n\nclass TestNodeWithAttributeChangedCallback:\n\n    @classmethod\n    def setup_class(cls):\n        registerNodeDesc(NodeWithAttributeChangedCallback)\n\n    @classmethod\n    def teardown_class(cls):\n        unregisterNodeDesc(NodeWithAttributeChangedCallback)\n\n    def test_assignValueTriggersCallback(self):\n        node = Node(NodeWithAttributeChangedCallback.__name__)\n        assert node.affectedInput.value == 0\n\n        node.input.value = 10\n        assert node.affectedInput.value == 20\n\n    def test_specifyDefaultValueDoesNotTriggerCallback(self):\n        node = Node(NodeWithAttributeChangedCallback.__name__, input=10)\n        assert node.affectedInput.value == 0\n\n    def test_assignDefaultValueDoesNotTriggerCallback(self):\n        node = Node(NodeWithAttributeChangedCallback.__name__, input=10)\n        node.input.value = 10\n        assert node.affectedInput.value == 0\n\n    def test_assignNonDefaultValueTriggersCallback(self):\n        node = Node(NodeWithAttributeChangedCallback.__name__, input=10)\n        node.input.value = 2\n        assert node.affectedInput.value == 4\n\n\nclass TestAttributeCallbackTriggerInGraph:\n\n    @classmethod\n    def setup_class(cls):\n        registerNodeDesc(NodeWithAttributeChangedCallback)\n\n    @classmethod\n    def teardown_class(cls):\n        unregisterNodeDesc(NodeWithAttributeChangedCallback)\n\n    def test_connectionTriggersCallback(self):\n        graph = Graph(\"\")\n        nodeA = graph.addNewNode(NodeWithAttributeChangedCallback.__name__)\n        nodeB = graph.addNewNode(NodeWithAttributeChangedCallback.__name__)\n\n        assert nodeA.affectedInput.value == nodeB.affectedInput.value == 0\n\n        nodeA.input.value = 1\n        nodeA.input.connectTo(nodeB.input)\n\n        assert nodeA.affectedInput.value == nodeB.affectedInput.value == 2\n\n    def test_connectedValueChangeTriggersCallback(self):\n        graph = Graph(\"\")\n        nodeA = graph.addNewNode(NodeWithAttributeChangedCallback.__name__)\n        nodeB = graph.addNewNode(NodeWithAttributeChangedCallback.__name__)\n\n        assert nodeA.affectedInput.value == nodeB.affectedInput.value == 0\n\n        nodeA.input.connectTo(nodeB.input)\n        nodeA.input.value = 1\n\n        assert nodeA.affectedInput.value == 2\n        assert nodeB.affectedInput.value == 2\n\n    def test_defaultValueOnlyTriggersCallbackDownstream(self):\n        graph = Graph(\"\")\n        nodeA = graph.addNewNode(NodeWithAttributeChangedCallback.__name__, input=1)\n        nodeB = graph.addNewNode(NodeWithAttributeChangedCallback.__name__)\n\n        assert nodeA.affectedInput.value == 0\n        assert nodeB.affectedInput.value == 0\n\n        nodeA.input.connectTo(nodeB.input)\n\n        assert nodeA.affectedInput.value == 0\n        assert nodeB.affectedInput.value == 2\n\n    def test_valueChangeIsPropagatedAlongNodeChain(self):\n        graph = Graph(\"\")\n        nodeA = graph.addNewNode(NodeWithAttributeChangedCallback.__name__)\n        nodeB = graph.addNewNode(NodeWithAttributeChangedCallback.__name__)\n        nodeC = graph.addNewNode(NodeWithAttributeChangedCallback.__name__)\n        nodeD = graph.addNewNode(NodeWithAttributeChangedCallback.__name__)\n\n        nodeA.affectedInput.connectTo(nodeB.input)\n        nodeB.affectedInput.connectTo(nodeC.input)\n        nodeC.affectedInput.connectTo(nodeD.input)\n\n        nodeA.input.value = 5\n\n        assert nodeA.affectedInput.value == nodeB.input.value == 10\n        assert nodeB.affectedInput.value == nodeC.input.value == 20\n        assert nodeC.affectedInput.value == nodeD.input.value == 40\n        assert nodeD.affectedInput.value == 80\n\n    def test_disconnectionTriggersCallback(self):\n        graph = Graph(\"\")\n        nodeA = graph.addNewNode(NodeWithAttributeChangedCallback.__name__)\n        nodeB = graph.addNewNode(NodeWithAttributeChangedCallback.__name__)\n\n        nodeA.input.connectTo(nodeB.input)\n        nodeA.input.value = 5\n        assert nodeB.affectedInput.value == 10\n\n        graph.removeEdge(nodeB.input)\n\n        assert nodeB.input.value == 0\n        assert nodeB.affectedInput.value == 0\n\n    def test_loadingGraphDoesNotTriggerCallback(self, graphSavedOnDisk):\n        graph: Graph = graphSavedOnDisk\n        node = graph.addNewNode(NodeWithAttributeChangedCallback.__name__)\n\n        node.input.value = 5\n        node.affectedInput.value = 2\n        graph.save()\n\n        loadedGraph = loadGraph(graph.filepath, strictCompatibility=True)\n        loadedNode = loadedGraph.node(node.name)\n        assert loadedNode\n        assert loadedNode.affectedInput.value == 2\n\n    def test_loadingGraphDoesNotTriggerCallbackForConnectedAttributes(\n        self, graphSavedOnDisk\n    ):\n        graph: Graph = graphSavedOnDisk\n        nodeA = graph.addNewNode(NodeWithAttributeChangedCallback.__name__)\n        nodeB = graph.addNewNode(NodeWithAttributeChangedCallback.__name__)\n\n        nodeA.input.connectTo(nodeB.input)\n        nodeA.input.value = 5\n        assert nodeB.affectedInput.value == nodeB.input.value * 2\n\n        nodeB.affectedInput.value = 2\n\n        graph.save()\n\n        loadedGraph = loadGraph(graph.filepath, strictCompatibility=True)\n        loadedNodeB = loadedGraph.node(nodeB.name)\n        assert loadedNodeB\n        assert loadedNodeB.affectedInput.value == 2\n\n\nclass NodeWithCompoundAttributes(desc.BaseNode):\n    \"\"\"\n    A Node containing a variation of compound attributes (List/Groups),\n    called whenever the value of this attribute is changed explicitly.\n    \"\"\"\n\n    inputs = [\n        desc.ListAttribute(\n            name=\"listInput\",\n            label=\"List Input\",\n            description=\"ListAttribute of IntParams.\",\n            elementDesc=desc.IntParam(\n                name=\"int\", label=\"Int\", description=\"\", value=0, range=None\n            ),\n        ),\n        desc.GroupAttribute(\n            name=\"groupInput\",\n            label=\"Group Input\",\n            description=\"GroupAttribute with a single 'IntParam' element.\",\n            items=[\n                desc.IntParam(\n                    name=\"int\", label=\"Int\", description=\"\", value=0, range=None\n                )\n            ],\n        ),\n        desc.ListAttribute(\n            name=\"listOfGroupsInput\",\n            label=\"List of Groups input\",\n            description=\"ListAttribute of GroupAttribute with a single 'IntParam' element.\",\n            elementDesc=desc.GroupAttribute(\n                name=\"subGroup\",\n                label=\"SubGroup\",\n                description=\"\",\n                items=[\n                    desc.IntParam(\n                        name=\"int\", label=\"Int\", description=\"\", value=0, range=None\n                    )\n                ],\n            )\n        ),\n        desc.GroupAttribute(\n            name=\"groupWithListInput\",\n            label=\"Group with List\",\n            description=\"GroupAttribute with a single 'ListAttribute of IntParam' element.\",\n            items=[\n                desc.ListAttribute(\n                    name=\"subList\",\n                    label=\"SubList\",\n                    description=\"\",\n                    elementDesc=desc.IntParam(\n                        name=\"int\", label=\"Int\", description=\"\", value=0, range=None\n                    )\n                )\n            ]\n        )\n    ]\n\n\nclass TestAttributeCallbackBehaviorWithUpstreamCompoundAttributes:\n\n    @classmethod\n    def setup_class(cls):\n        registerNodeDesc(NodeWithAttributeChangedCallback)\n        registerNodeDesc(NodeWithCompoundAttributes)\n\n    @classmethod\n    def teardown_class(cls):\n        unregisterNodeDesc(NodeWithAttributeChangedCallback)\n        unregisterNodeDesc(NodeWithCompoundAttributes)\n\n    def test_connectionToListElement(self):\n        graph = Graph(\"\")\n        nodeA = graph.addNewNode(NodeWithCompoundAttributes.__name__)\n        nodeB = graph.addNewNode(NodeWithAttributeChangedCallback.__name__)\n\n        nodeA.listInput.append(0)\n        attr = nodeA.listInput.at(0)\n\n        attr.connectTo(nodeB.input)\n\n        attr.value = 10\n\n        assert nodeB.input.value == 10\n        assert nodeB.affectedInput.value == 20\n\n    def test_connectionToGroupElement(self):\n        graph = Graph(\"\")\n        nodeA = graph.addNewNode(NodeWithCompoundAttributes.__name__)\n        nodeB = graph.addNewNode(NodeWithAttributeChangedCallback.__name__)\n\n        nodeA.groupInput.int.connectTo(nodeB.input)\n\n        nodeA.groupInput.int.value = 10\n\n        assert nodeB.input.value == 10\n        assert nodeB.affectedInput.value == 20\n\n    def test_connectionToGroupElementInList(self):\n        graph = Graph(\"\")\n        nodeA = graph.addNewNode(NodeWithCompoundAttributes.__name__)\n        nodeB = graph.addNewNode(NodeWithAttributeChangedCallback.__name__)\n\n        nodeA.listOfGroupsInput.append({})\n\n        attr = nodeA.listOfGroupsInput.at(0)\n\n        attr.int.connectTo(nodeB.input)\n\n        attr.int.value = 10\n\n        assert nodeB.input.value == 10\n        assert nodeB.affectedInput.value == 20\n\n    def test_connectionToListElementInGroup(self):\n        graph = Graph(\"\")\n        nodeA = graph.addNewNode(NodeWithCompoundAttributes.__name__)\n        nodeB = graph.addNewNode(NodeWithAttributeChangedCallback.__name__)\n\n        nodeA.groupWithListInput.subList.append(0)\n\n        attr = nodeA.groupWithListInput.subList.at(0)\n\n        attr.connectTo(nodeB.input)\n\n        attr.value = 10\n\n        assert nodeB.input.value == 10\n        assert nodeB.affectedInput.value == 20\n\n\nclass NodeWithDynamicOutputValue(desc.BaseNode):\n    \"\"\"\n    A Node containing an output attribute which value is computed dynamically\n    during graph execution.\n    \"\"\"\n\n    inputs = [\n        desc.IntParam(\n            name=\"input\",\n            label=\"Input\",\n            description=\"Input used in the computation of 'output'\",\n            value=0,\n        ),\n    ]\n\n    outputs = [\n        desc.IntParam(\n            name=\"output\",\n            label=\"Output\",\n            description=\"Dynamically computed output (input * 2)\",\n            # Setting value to None makes the attribute dynamic.\n            value=None,\n        ),\n    ]\n\n    def processChunk(self, chunk):\n        chunk.node.output.value = chunk.node.input.value * 2\n\n\nclass TestAttributeCallbackBehaviorWithUpstreamDynamicOutputs:\n    # nodePluginAttributeChangedCallback = NodePlugin(NodeWithAttributeChangedCallback)\n    # nodePluginDynamicOutputValue = NodePlugin(NodeWithDynamicOutputValue)\n\n    @classmethod\n    def setup_class(cls):\n        registerNodeDesc(NodeWithAttributeChangedCallback)\n        registerNodeDesc(NodeWithDynamicOutputValue)\n\n    @classmethod\n    def teardown_class(cls):\n        unregisterNodeDesc(NodeWithAttributeChangedCallback)\n        unregisterNodeDesc(NodeWithDynamicOutputValue)\n\n    def test_connectingUncomputedDynamicOutputDoesNotTriggerDownstreamAttributeChangedCallback(\n        self,\n    ):\n        graph = Graph(\"\")\n        nodeA = graph.addNewNode(NodeWithDynamicOutputValue.__name__)\n        nodeB = graph.addNewNode(NodeWithAttributeChangedCallback.__name__)\n\n        nodeA.input.value = 10\n        nodeA.output.connectTo(nodeB.input)\n\n        assert nodeB.affectedInput.value == 0\n\n    def test_connectingComputedDynamicOutputTriggersDownstreamAttributeChangedCallback(\n        self, graphSavedOnDisk\n    ):\n        graph: Graph = graphSavedOnDisk\n        nodeA = graph.addNewNode(NodeWithDynamicOutputValue.__name__)\n        nodeB = graph.addNewNode(NodeWithAttributeChangedCallback.__name__)\n\n        nodeA.input.value = 10\n        executeGraph(graph)\n\n        nodeA.output.connectTo(nodeB.input)\n        assert nodeA.output.value == nodeB.input.value == 20\n        assert nodeB.affectedInput.value == 40\n\n    def test_dynamicOutputValueComputeDoesNotTriggerDownstreamAttributeChangedCallback(\n        self, graphSavedOnDisk\n    ):\n        graph: Graph = graphSavedOnDisk\n        nodeA = graph.addNewNode(NodeWithDynamicOutputValue.__name__)\n        nodeB = graph.addNewNode(NodeWithAttributeChangedCallback.__name__)\n\n        nodeA.output.connectTo(nodeB.input)\n        nodeA.input.value = 10\n        executeGraph(graph)\n\n        assert nodeB.input.value == 20\n        assert nodeB.affectedInput.value == 0\n\n    def test_clearingDynamicOutputValueDoesNotTriggerDownstreamAttributeChangedCallback(\n        self, graphSavedOnDisk\n    ):\n        graph: Graph = graphSavedOnDisk\n        nodeA = graph.addNewNode(NodeWithDynamicOutputValue.__name__)\n        nodeB = graph.addNewNode(NodeWithAttributeChangedCallback.__name__)\n\n        nodeA.input.value = 10\n        executeGraph(graph)\n\n        nodeA.output.connectTo(nodeB.input)\n\n        expectedPreClearValue = nodeA.input.value * 2 * 2\n        assert nodeB.affectedInput.value == expectedPreClearValue\n\n        nodeA.clearData()\n        assert nodeA.output.value == nodeB.input.value is None\n        assert nodeB.affectedInput.value == expectedPreClearValue\n\n    def test_loadingGraphWithComputedDynamicOutputValueDoesNotTriggerDownstreamAttributeChangedCallback(\n        self, graphSavedOnDisk\n    ):\n        graph: Graph = graphSavedOnDisk\n        nodeA = graph.addNewNode(NodeWithDynamicOutputValue.__name__)\n        nodeB = graph.addNewNode(NodeWithAttributeChangedCallback.__name__)\n\n        nodeA.input.value = 10\n        nodeA.output.connectTo(nodeB.input)\n        executeGraph(graph)\n\n        assert nodeA.output.value == nodeB.input.value == 20\n        assert nodeB.affectedInput.value == 0\n\n        graph.save()\n\n        loadGraph(graph.filepath, strictCompatibility=True)\n\n        assert nodeB.affectedInput.value == 0\n\n\nclass TestAttributeCallbackBehaviorOnGraphImport:\n    @classmethod\n    def setup_class(cls):\n        registerNodeDesc(NodeWithAttributeChangedCallback)\n\n    @classmethod\n    def teardown_class(cls):\n        unregisterNodeDesc(NodeWithAttributeChangedCallback)\n\n    def test_importingGraphDoesNotTriggerAttributeChangedCallbacks(self):\n        graph = Graph(\"\")\n\n        nodeA = graph.addNewNode(NodeWithAttributeChangedCallback.__name__)\n        nodeB = graph.addNewNode(NodeWithAttributeChangedCallback.__name__)\n\n        nodeA.affectedInput.connectTo(nodeB.input)\n\n        nodeA.input.value = 5\n        nodeB.affectedInput.value = 2\n\n        otherGraph = Graph(\"\")\n        otherGraph.importGraphContent(graph)\n\n        assert otherGraph.node(nodeB.name).affectedInput.value == 2\n"
  },
  {
    "path": "tests/test_nodeAttributesFormatting.py",
    "content": "#!/usr/bin/env python\n# coding:utf-8\n\nfrom meshroom.core.graph import Graph\nfrom meshroom.core import desc\n\nfrom .utils import registerNodeDesc, unregisterNodeDesc\n\n\nclass NodeWithAttributesNeedingFormatting(desc.Node):\n    \"\"\"\n    A node containing list, file, choice and group attributes in order to test the\n    formatting of the command line.\n    \"\"\"\n    inputs = [\n        desc.ListAttribute(\n            name=\"images\",\n            label=\"Images\",\n            description=\"List of images.\",\n            elementDesc=desc.File(\n                name=\"image\",\n                label=\"Image\",\n                description=\"Path to an image.\",\n                value=\"\",\n            ),\n        ),\n        desc.File(\n            name=\"input\",\n            label=\"Input File\",\n            description=\"An input file.\",\n            value=\"\",\n        ),\n        desc.ChoiceParam(\n            name=\"method\",\n            label=\"Method\",\n            description=\"Method to choose from a list of available methods.\",\n            value=\"MethodC\",\n            values=[\"MethodA\", \"MethodB\", \"MethodC\"],\n        ),\n        desc.GroupAttribute(\n            name=\"firstGroup\",\n            label=\"First Group\",\n            description=\"Group with boolean and integer parameters.\",\n            joinChar=\":\",\n            items=[\n                desc.BoolParam(\n                    name=\"enableFirstGroup\",\n                    label=\"Enable\",\n                    description=\"Enable other parameter in the group.\",\n                    value=False,\n                ),\n                desc.IntParam(\n                    name=\"width\",\n                    label=\"Width\",\n                    description=\"Width setting.\",\n                    value=3,\n                    range=(1, 10, 1),\n                    enabled=lambda node: node.firstGroup.enableFirstGroup.value,\n                ),\n            ]\n        ),\n        desc.GroupAttribute(\n            name=\"secondGroup\",\n            label=\"Second Group\",\n            description=\"Group with boolean, choice and float parameters.\",\n            joinChar=\",\",\n            items=[\n                desc.BoolParam(\n                    name=\"enableSecondGroup\",\n                    label=\"Enable\",\n                    description=\"Enable other parameters in the group.\",\n                    value=False,\n                ),\n                desc.ChoiceParam(\n                    name=\"groupChoice\",\n                    label=\"Grouped Choice\",\n                    description=\"Value to choose from a group.\",\n                    value=\"second_value\",\n                    values=[\"first_value\", \"second_value\", \"third_value\"],\n                    enabled=lambda node: node.secondGroup.enableSecondGroup.value,\n                ),\n                desc.FloatParam(\n                    name=\"floatWidth\",\n                    label=\"Width\",\n                    description=\"Width setting (but with a float).\",\n                    value=3.0,\n                    range=(1.0, 10.0, 0.5),\n                    enabled=lambda node: node.secondGroup.enableSecondGroup.value,\n                ),\n            ],\n        ),\n    ]\n\n    outputs = [\n        desc.File(\n            name=\"output\",\n            label=\"Output\",\n            description=\"Output file.\",\n            value=\"{nodeCacheFolder}\",\n        ),\n    ]\n\n\nclass TestAttributesFormatting:\n\n    @classmethod\n    def setup_class(cls):\n        registerNodeDesc(NodeWithAttributesNeedingFormatting)\n\n    @classmethod\n    def teardown_class(cls):\n        unregisterNodeDesc(NodeWithAttributesNeedingFormatting)\n\n    def test_formatting_listOfFiles(self):\n        inputImages = [\"/non/existing/fileA\", \"/non/existing/with space/fileB\"]\n\n        graph = Graph(\"\")\n        node = graph.addNewNode(\"NodeWithAttributesNeedingFormatting\")\n\n        # Assert that an empty list gives an empty string\n        assert node.images.getValueStr() == \"\"\n\n        # Assert that values in a list a correctly concatenated\n        node.images.extend([i for i in inputImages])\n        assert node.images.getValueStr() == '\"/non/existing/fileA\" \"/non/existing/with space/fileB\"'\n\n        # Reset list content and add a single value that contains spaces\n        node.images.resetToDefaultValue()\n        assert node.images.getValueStr() == \"\"  # The value has been correctly reset\n        node.images.extend(\"single value with space\")\n        assert node.images.getValueStr() == '\"single value with space\"'\n\n        # Assert that extending values when the list is not empty is working\n        node.images.extend(inputImages)\n        assert node.images.getValueStr() == \\\n            '\"single value with space\" \"{}\" \"{}\"'.format(inputImages[0],\n                                                         inputImages[1])\n\n        # Values are not retrieved as strings in the command line, so quotes around them are\n        # not expected\n        assert node._expVars[\"imagesValue\"] == \\\n            'single value with space {} {}'.format(inputImages[0],\n                                                   inputImages[1])\n\n    def test_formatting_strings(self):\n        graph = Graph(\"\")\n        node = graph.addNewNode(\"NodeWithAttributesNeedingFormatting\")\n        node._buildExpVars()\n\n        # Assert an empty File attribute generates empty quotes when requesting its value as\n        # a string\n        assert node.input.getValueStr() == '\"\"'\n        assert node._expVars[\"inputValue\"] == \"\"\n\n        # Assert a Choice attribute with a non-empty default value is surrounded with quotes\n        # when requested as a string\n        assert node.method.getValueStr() == '\"MethodC\"'\n        assert node._expVars[\"methodValue\"] == \"MethodC\"\n\n        # Assert that the empty list is really empty (no quotes)\n        assert node.images.getValueStr() == \"\"\n        assert node._expVars[\"imagesValue\"] == \"\", \"Empty list should become fully empty\"\n\n        # Assert that the list with one empty value generates empty quotes\n        node.images.extend(\"\")\n        assert node.images.getValueStr() == '\"\"', \\\n            \"A list with one empty string should generate empty quotes\"\n        assert node._expVars[\"imagesValue\"] == \"\", \\\n            \"The value is always only the value, so empty here\"\n\n        # Assert that a list with 2 empty strings generates quotes\n        node.images.extend(\"\")\n        assert node.images.getValueStr() == '\"\" \"\"', \\\n            \"A list with 2 empty strings should generate quotes\"\n        assert node._expVars[\"imagesValue\"] == ' ', \\\n            \"The value is always only the value, so 2 empty strings with the \" \\\n            \"space separator in the middle\"\n\n    def test_formatting_groups(self):\n        graph = Graph(\"\")\n        node = graph.addNewNode(\"NodeWithAttributesNeedingFormatting\")\n        node._buildExpVars()\n\n        assert node.firstGroup.getValueStr() == '\"False:3\"'\n        assert node._expVars[\"firstGroupValue\"] == 'False:3', \\\n            \"There should be no quotes here as the value is not formatted as a string\"\n\n        assert node.secondGroup.getValueStr() == '\"False,second_value,3.0\"'\n        assert node._expVars[\"secondGroupValue\"] == 'False,second_value,3.0'\n"
  },
  {
    "path": "tests/test_nodeCallbacks.py",
    "content": "from meshroom.core import desc\nfrom meshroom.core.node import Node\nfrom meshroom.core.graph import Graph, loadGraph\n\nfrom .utils import registerNodeDesc, unregisterNodeDesc\n\n\nclass NodeWithCreationCallback(desc.InputNode):\n    \"\"\"Node defining an 'onNodeCreated' callback, triggered a new node is added to a Graph.\"\"\"\n\n    inputs = [\n        desc.BoolParam(\n            name=\"triggered\",\n            label=\"Triggered\",\n            description=\"Attribute impacted by the `onNodeCreated` callback\",\n            value=False,\n        ),\n    ]\n\n    @classmethod\n    def onNodeCreated(cls, node: Node):\n        \"\"\"Triggered when a new node is created within a Graph.\"\"\"\n        node.triggered.value = True\n\n\nclass TestNodeCreationCallback:\n\n    @classmethod\n    def setup_class(cls):\n        registerNodeDesc(NodeWithCreationCallback)\n\n    @classmethod\n    def teardown_class(cls):\n        unregisterNodeDesc(NodeWithCreationCallback)\n\n    def test_notTriggeredOnNodeInstantiation(self):\n        node = Node(NodeWithCreationCallback.__name__)\n        assert node.triggered.value is False\n\n    def test_triggeredOnNewNodeCreationInGraph(self):\n        graph = Graph(\"\")\n        node = graph.addNewNode(NodeWithCreationCallback.__name__)\n        assert node.triggered.value is True\n\n    def test_notTriggeredOnNodeDuplication(self):\n        graph = Graph(\"\")\n        node = graph.addNewNode(NodeWithCreationCallback.__name__)\n        node.triggered.resetToDefaultValue()\n\n        duplicates = graph.duplicateNodes([node])\n        assert duplicates[node][0].triggered.value is False\n\n    def test_notTriggeredOnGraphLoad(self, graphSavedOnDisk):\n        graph: Graph = graphSavedOnDisk\n        node = graph.addNewNode(NodeWithCreationCallback.__name__)\n        node.triggered.resetToDefaultValue()\n        graph.save()\n\n        loadedGraph = loadGraph(graph.filepath)\n        assert loadedGraph.node(node.name).triggered.value is False\n\n    def test_triggeredOnGraphInitializationFromTemplate(self, graphSavedOnDisk):\n        graph: Graph = graphSavedOnDisk\n        node = graph.addNewNode(NodeWithCreationCallback.__name__)\n        node.triggered.resetToDefaultValue()\n        graph.save(template=True)\n\n        graphFromTemplate = Graph(\"\")\n        graphFromTemplate.initFromTemplate(graph.filepath)\n\n        assert graphFromTemplate.node(node.name).triggered.value is True\n"
  },
  {
    "path": "tests/test_nodeCommandLineFormatting.py",
    "content": "#!/usr/bin/env python\n# coding:utf-8\n\nfrom meshroom.core.graph import Graph\nfrom meshroom.core import desc\n\nfrom .utils import registerNodeDesc, unregisterNodeDesc\n\n\nclass NodeWithCommandLineFormatting_usingNodeAndLambda(desc.CommandLineNode):\n    \"\"\"\n    A node using a lambda for the commandLine member variable.\n    \"\"\"\n    commandLine = lambda node: f\"myapp --input {node.input.value} --output {node.output.value}\"\n\n    inputs = [\n        desc.File(\n            name=\"input\",\n            label=\"Input File\",\n            description=\"An input file.\",\n            value=\"/some/input\",\n        ),\n    ]\n\n    outputs = [\n        desc.File(\n            name=\"output\",\n            label=\"Output\",\n            description=\"Output file.\",\n            value=\"output.txt\",\n        ),\n    ]\n\ndef customFunction_commandline(node):\n    return f\"myapp --input {node.input.value} --output {node.output.value}\"\n\n\nclass NodeWithCommandLineFormatting_usingNodeAndFunction(desc.CommandLineNode):\n    \"\"\"\n    A node using a function for the commandLine member variable.\n    \"\"\"\n    commandLine = customFunction_commandline\n\n    inputs = [\n        desc.File(\n            name=\"input\",\n            label=\"Input File\",\n            description=\"An input file.\",\n            value=\"/some/input\",\n        ),\n    ]\n\n    outputs = [\n        desc.File(\n            name=\"output\",\n            label=\"Output\",\n            description=\"Output file.\",\n            value=\"output.txt\",\n        ),\n    ]\n\n\nclass NodeWithCommandLineFormatting_usingNode(desc.CommandLineNode):\n    \"\"\"\n    A node using a lambda for the commandLine member variable.\n    \"\"\"\n    commandLine = \"myapp --input {node.input.value} --output {node.output.value}\"\n\n    inputs = [\n        desc.File(\n            name=\"input\",\n            label=\"Input File\",\n            description=\"An input file.\",\n            value=\"/some/input\",\n        ),\n    ]\n\n    outputs = [\n        desc.File(\n            name=\"output\",\n            label=\"Output\",\n            description=\"Output file.\",\n            value=\"output.txt\",\n        ),\n    ]\n\n\nclass NodeWithCommandLineFormatting_usingValue(desc.CommandLineNode):\n    \"\"\"\n    A node using a string template for the commandLine member variable.\n    \"\"\"\n    commandLine = \"myapp --input {inputValue} --output {outputValue}\"\n\n    inputs = [\n        desc.File(\n            name=\"input\",\n            label=\"Input File\",\n            description=\"An input file.\",\n            value=\"/some/input\",\n        ),\n    ]\n\n    outputs = [\n        desc.File(\n            name=\"output\",\n            label=\"Output\",\n            description=\"Output file.\",\n            value=\"output.txt\",\n        ),\n    ]\n\n\nclass TestCommandLineFormatting:\n\n    @classmethod\n    def setup_class(cls):\n        registerNodeDesc(NodeWithCommandLineFormatting_usingNodeAndLambda)\n        registerNodeDesc(NodeWithCommandLineFormatting_usingNodeAndFunction)\n        registerNodeDesc(NodeWithCommandLineFormatting_usingNode)\n        registerNodeDesc(NodeWithCommandLineFormatting_usingValue)\n\n    @classmethod\n    def teardown_class(cls):\n        unregisterNodeDesc(NodeWithCommandLineFormatting_usingNodeAndLambda)\n        unregisterNodeDesc(NodeWithCommandLineFormatting_usingNodeAndFunction)\n        unregisterNodeDesc(NodeWithCommandLineFormatting_usingNode)\n        unregisterNodeDesc(NodeWithCommandLineFormatting_usingValue)\n\n    def test_commandLine_node(self):\n        graph = Graph(\"\")\n        nodeNL = graph.addNewNode(\"NodeWithCommandLineFormatting_usingNodeAndLambda\")\n        nodeNF = graph.addNewNode(\"NodeWithCommandLineFormatting_usingNodeAndFunction\")\n        nodeN = graph.addNewNode(\"NodeWithCommandLineFormatting_usingNode\")\n        nodeV = graph.addNewNode(\"NodeWithCommandLineFormatting_usingValue\")\n\n        nodeNL.input.value = \"/path/in\"\n        nodeNF.input.value = \"/path/in\"\n        nodeN.input.value = \"/path/in\"\n        nodeV.input.value = \"/path/in\"\n\n        nodeNL._buildExpVars()  # populate _expVars\n        nodeNF._buildExpVars()  # populate _expVars\n        nodeN._buildExpVars()  # populate _expVars\n        nodeV._buildExpVars()  # populate _expVars\n\n        cmdNL = nodeNL.nodeDesc.buildCommandLine(nodeNL.chunks[0])\n        cmdNF = nodeNL.nodeDesc.buildCommandLine(nodeNF.chunks[0])\n        cmdN = nodeN.nodeDesc.buildCommandLine(nodeN.chunks[0])\n        cmdV = nodeV.nodeDesc.buildCommandLine(nodeV.chunks[0])\n\n        assert cmdNL\n        assert cmdNF\n        assert cmdN\n        assert cmdV\n\n        assert cmdNL == cmdNF\n        assert cmdN == cmdNL\n        assert cmdN == cmdV\n\n"
  },
  {
    "path": "tests/test_nodeDynamicOutputs.py",
    "content": "import pytest\n\nfrom meshroom.core import desc\nfrom meshroom.core import pluginManager\nfrom meshroom.core.exception import UnknownNodeTypeError\nfrom meshroom.core.graph import Graph, loadGraph\nfrom meshroom.core.plugins import NodePluginStatus\n\nfrom .utils import registerNodeDesc, unregisterNodeDesc\n\n\nclass NodeWithDynamicOutputs(desc.Node):\n    inputs = [\n        desc.BoolParam(\n            name=\"boolInput\",\n            label=\"Bool Input\",\n            description=\"A boolean input.\",\n            value=False,\n        ),\n        desc.File(\n            name=\"fileInput\",\n            label=\"File Input\",\n            description=\"A file input.\",\n            value=\"testFile\",\n        ),\n        desc.StringParam(\n            name=\"stringInput\",\n            label=\"String Input\",\n            description=\"A string input.\",\n            value=\"testString\",\n        ),\n        desc.IntParam(\n            name=\"intInput\",\n            label=\"Int Input\",\n            description=\"An integer input.\",\n            value=1,\n        ),\n        desc.FloatParam(\n            name=\"floatInput\",\n            label=\"Float Input\",\n            description=\"A floating input.\",\n            value=5.0,\n        ),\n    ]\n\n    outputs = [\n        desc.BoolParam(\n            name=\"boolOutput\",\n            label=\"Bool Output\",\n            description=\"A boolean output.\",\n            value=None,\n        ),\n        desc.File(\n            name=\"fileOutput\",\n            label=\"File Output\",\n            description=\"A file Output.\",\n            value=None,\n        ),\n        desc.StringParam(\n            name=\"stringOutput\",\n            label=\"String Output\",\n            description=\"A string output.\",\n            value=None,\n        ),\n        desc.IntParam(\n            name=\"intOutput\",\n            label=\"Int Output\",\n            description=\"An integer output.\",\n            value=None,\n        ),\n        desc.FloatParam(\n            name=\"floatOutput\",\n            label=\"Float Output\",\n            description=\"A floating output.\",\n            value=None,\n        ),\n    ]\n\n    def process(self, node):\n        print(\"Processing NodeWithDynamicOutputs\")\n        node.boolOutput.value = not node.boolInput.value\n        node.fileOutput.value = node.fileInput.value + \".ext\"\n        node.stringOutput.value = node.stringInput.value.upper()\n        node.intOutput.value = node.intInput.value + 1\n        node.floatOutput.value = node.floatInput.value * 2.0\n\n\nclass InputNodeWithDynamicOutputs(desc.InputNode):\n    inputs = [\n        desc.File(\n            name=\"fileInput\",\n            label=\"File Input\",\n            description=\"A file input.\",\n            value=\"testFile\",\n        ),\n    ]\n\n    outputs = [\n        desc.File(\n            name=\"fileOutput\",\n            label=\"File Output\",\n            description=\"A file Output.\",\n            value=None,\n        ),\n    ]\n\n\nclass TestNodesWithDynamicOutputs:\n    @classmethod\n    def setup_class(cls):\n        registerNodeDesc(NodeWithDynamicOutputs)\n\n    @classmethod\n    def teardown_class(cls):\n        unregisterNodeDesc(NodeWithDynamicOutputs)\n\n    def test_processWithDynamicOutputs(self, graphSavedOnDisk):\n        graph: Graph = graphSavedOnDisk\n        node = graph.addNewNode(NodeWithDynamicOutputs.__name__)\n\n        # Execute the node to compute dynamic outputs\n        node.process(inCurrentEnv=True)\n\n        assert node.boolOutput.value\n        assert node.fileOutput.value == \"testFile.ext\"\n        assert node.stringOutput.value == \"TESTSTRING\"\n        assert node.intOutput.value == 2\n        assert node.floatOutput.value == 10.0\n\n    def test_processWithDynamicOutputsNonDefaultInputs(self, graphSavedOnDisk):\n        graph: Graph = graphSavedOnDisk\n        node = graph.addNewNode(NodeWithDynamicOutputs.__name__)\n\n        node.boolInput.value = True\n        node.fileInput.value = \"anotherTestFile\"\n        node.stringInput.value = \"anotherTestString\"\n        node.intInput.value = 10\n        node.floatInput.value = 3.5\n\n        # Execute the node to compute dynamic outputs\n        node.process(inCurrentEnv=True)\n\n        assert not node.boolOutput.value\n        assert node.fileOutput.value == \"anotherTestFile.ext\"\n        assert node.stringOutput.value == \"ANOTHERTESTSTRING\"\n        assert node.intOutput.value == 11\n        assert node.floatOutput.value == 7.0\n\n    def test_loadGraphWithUncomputedDynamicOutputs(self, graphSavedOnDisk):\n        graph: Graph = graphSavedOnDisk\n        node = graph.addNewNode(NodeWithDynamicOutputs.__name__)\n        graph.save()\n\n        loadedGraph = loadGraph(graph.filepath)\n        loadedNode = loadedGraph.node(node.name)\n\n        assert loadedNode\n        assert loadedNode.boolOutput.value is None\n        assert loadedNode.fileOutput.value is None\n        assert loadedNode.stringOutput.value is None\n        assert loadedNode.intOutput.value is None\n        assert loadedNode.floatOutput.value is None\n\n    def test_loadGraphWithComputedDynamicOutputs(self, graphSavedOnDisk):\n        graph: Graph = graphSavedOnDisk\n        node = graph.addNewNode(NodeWithDynamicOutputs.__name__)\n        name = node.name\n        graph.save()\n\n        # Execute the node to compute dynamic outputs\n        node.process(inCurrentEnv=True)\n\n        # Check that the values have been correctly set\n        assert node.boolOutput.value\n        assert node.fileOutput.value == \"testFile.ext\"\n        assert node.stringOutput.value == \"TESTSTRING\"\n        assert node.intOutput.value == 2\n        assert node.floatOutput.value == 10.0\n\n        # Reload the graph from disk\n        loadedGraph = loadGraph(graph.filepath)\n        loadedNode = loadedGraph.node(name)\n\n        # Check that the dynamic outputs have been correctly deserialized\n        assert loadedNode\n        assert loadedNode.boolOutput.value\n        assert loadedNode.fileOutput.value == \"testFile.ext\"\n        assert loadedNode.stringOutput.value == \"TESTSTRING\"\n        assert loadedNode.intOutput.value == 2\n        assert loadedNode.floatOutput.value == 10.0\n\n\nclass TestInputNodeWithDynamicOutputs:\n    def test_registerInputNodeWithDynamicOutputs(self):\n        \"\"\"\n        Force the registration of a node with an invalid description and check that its description is rejected\n        and its status states it clearly.\n        \"\"\"\n        registerNodeDesc(InputNodeWithDynamicOutputs)\n\n        # Check that the plugin has been correctly registered (there has been attempt to load it)\n        assert pluginManager.isRegistered(InputNodeWithDynamicOutputs.__name__)\n\n        # Check that the plugin's status is DESC_ERROR, since the node description is invalid\n        # Additionally, the list of errors should include an error about having a dynamic output in an InputNode\n        plugin = pluginManager.getRegisteredNodePlugin(InputNodeWithDynamicOutputs.__name__)\n        assert plugin\n        assert plugin.status == NodePluginStatus.DESC_ERROR\n        assert len(plugin.errors) == 1\n        errType = plugin.errors[0][1]\n        assert errType == desc.ValueTypeErrors.DYNAMIC_OUTPUT\n\n        unregisterNodeDesc(InputNodeWithDynamicOutputs)\n\n    def test_registerInputNodeWithDynamicOutputsV2(self):\n        \"\"\"\" Check that an input node with dynamic outputs has not been registered because it is invalid. \"\"\"\n        graph = Graph(\"\")\n        with pytest.raises(UnknownNodeTypeError):\n            # InputDynamicOutputs is located in tests/nodes/test/InputDynamicOutputs.py\n            # InputDynamicOutputs has the same description as InputNodeWithDynamicOutputs: had it been valid, it would\n            # have been loaded and registered by the plugin manager at the upper level of the test suite.\n            graph.addNewNode(\"InputDynamicOutputs\")\n"
  },
  {
    "path": "tests/test_nodes.py",
    "content": "#!/usr/bin/env python\n# coding:utf-8\n\nimport os\nfrom pathlib import Path\nimport tempfile\n\nfrom meshroom.core import desc, pluginManager, loadClassesNodes, initNodes\nfrom meshroom.core.graph import Graph, loadGraph\nfrom meshroom.core.plugins import Plugin\n\n\nfrom .utils import registerNodeDesc, unregisterNodeDesc, registeredNodeTypes\n\n\nclass TestNodeInfo:\n    plugin = None\n\n    @classmethod\n    def setup_class(cls):\n        cls.folder = os.path.join(os.path.dirname(__file__), \"plugins\", \"meshroom\")\n        package = \"pluginC\"\n        cls.plugin = Plugin(package, cls.folder)\n        nodes = loadClassesNodes(cls.folder, package)\n        for node in nodes:\n            cls.plugin.addNodePlugin(node)\n        pluginManager.addPlugin(cls.plugin)\n\n    @classmethod\n    def teardown_class(cls):\n        for node in cls.plugin.nodes.values():\n            pluginManager.unregisterNode(node)\n        pluginManager.removePlugin(cls.plugin)\n        cls.plugin = None\n\n    def test_loadedPlugin(self):\n        assert len(pluginManager.getPlugins()) >= 1\n        plugin = pluginManager.getPlugin(\"pluginC\")\n        assert plugin == self.plugin\n        node = plugin.nodes[\"PluginCNodeA\"]\n        nodeType = node.nodeDescriptor\n\n        g = Graph(\"\")\n        registerNodeDesc(nodeType)\n        node = g.addNewNode(nodeType.__name__)\n\n        nodeDocumentation = node.getDocumentation()\n        assert nodeDocumentation == \"PluginCNodeA\"\n        nodeInfo = {item[\"key\"]: item[\"value\"] for item in node.getNodeInfo()}\n        assert nodeInfo[\"module\"] == \"pluginC.PluginCNodeA\"\n        pluginPath = os.path.join(self.folder, \"pluginC\", \"PluginCNodeA.py\")\n        assert nodeInfo[\"modulePath\"] == Path(pluginPath).as_posix()  # modulePath seems to follow Linux convention\n        assert nodeInfo[\"author\"] == \"testAuthor\"\n        assert nodeInfo[\"license\"] == \"no-license\"\n        assert nodeInfo[\"version\"] == \"1.0\"\n        unregisterNodeDesc(nodeType)\n\n\nclass TestNodeVariables:\n    plugin = None\n\n    @classmethod\n    def setup_class(cls):\n        folder = os.path.join(os.path.dirname(__file__), \"plugins\", \"meshroom\")\n        package = \"pluginA\"\n        cls.plugin = Plugin(package, folder)\n        nodes = loadClassesNodes(folder, package)\n        for node in nodes:\n            cls.plugin.addNodePlugin(node)\n        pluginManager.addPlugin(cls.plugin)\n\n    @classmethod\n    def teardown_class(cls):\n        for node in cls.plugin.nodes.values():\n            pluginManager.unregisterNode(node)\n        pluginManager.removePlugin(cls.plugin)\n        cls.plugin = None\n\n    def test_staticVariables(self):\n        g = Graph(\"\")\n\n        for nodeName in self.plugin.nodes.keys():\n            n = g.addNewNode(nodeName)\n            assert nodeName == n._staticExpVars[\"nodeType\"]\n            assert n.sourceCodeFolder\n            assert n.sourceCodeFolder == n._staticExpVars[\"nodeSourceCodeFolder\"]\n\n            self.plugin.nodes[nodeName].reload()\n\n            assert nodeName == n._staticExpVars[\"nodeType\"]\n            assert n.sourceCodeFolder\n            assert n.sourceCodeFolder == n._staticExpVars[\"nodeSourceCodeFolder\"]\n\n    def test_expVariables(self):\n        g = Graph(\"\")\n\n        for nodeName in self.plugin.nodes.keys():\n            n = g.addNewNode(nodeName)\n            assert n._expVars[\"uid\"] == n._uid\n            assert n.internalFolder\n            assert n.internalFolder == n._expVars[\"nodeCacheFolder\"]\n            assert \"node\" in n._expVars\n            assert n._expVars[\"node\"] is n\n\n            self.plugin.nodes[nodeName].reload()\n\n            assert n._expVars[\"uid\"] == n._uid\n            assert n.internalFolder\n            assert n.internalFolder == n._expVars[\"nodeCacheFolder\"]\n            assert \"node\" in n._expVars\n            assert n._expVars[\"node\"] is n\n\n\nclass TestInitNode:\n    plugin = None\n\n    @classmethod\n    def setup_class(cls):\n        folder = os.path.join(os.path.dirname(__file__), \"plugins\", \"meshroom\")\n        package = \"pluginA\"\n        cls.plugin = Plugin(package, folder)\n        nodes = loadClassesNodes(folder, package)\n        for node in nodes:\n            cls.plugin.addNodePlugin(node)\n        pluginManager.addPlugin(cls.plugin)\n\n    @classmethod\n    def teardown_class(cls):\n        for node in cls.plugin.nodes.values():\n            pluginManager.unregisterNode(node)\n        pluginManager.removePlugin(cls.plugin)\n        cls.plugin = None\n\n    def test_initNode(self):\n        g = Graph(\"\")\n\n        node = g.addNewNode(\"PluginAInputInitNode\")\n\n        # Check that the init node is correctly detected\n        initNodes = g.findInitNodes()\n        assert len(initNodes) == 1 and node in initNodes\n\n        # Check that the init node's initialize method has been set\n        inputs = [\"/path/to/file\", \"/path/to/file/2\"]\n        node.nodeDesc.initialize(node, inputs, None)\n        assert node.input.value == inputs[0]\n\n\nclass TestBackdropNode:\n    loadedPlugins = pluginManager.getPlugins()\n\n    @classmethod\n    def setup_class(cls):\n        initNodes()\n\n    @classmethod\n    def teardown_class(cls):\n        for plugin in pluginManager.getPlugins():\n            if plugin not in cls.loadedPlugins:\n                for node in plugin.nodes.values():\n                    pluginManager.unregisterNode(node)\n                pluginManager.removePlugin(plugin)\n\n    def test_backdropNode(self):\n        \"\"\" Test that a backdrop node can be added to a graph with its expected default values. \"\"\"\n        g = Graph(\"Default Backdrop node\")\n        backdrop = g.addNewNode(\"Backdrop\")\n\n        # Check that the default values for backdrop are as expected\n        assert backdrop is not None\n        assert backdrop.nodeWidth == 600\n        assert backdrop.nodeHeight == 400\n        assert backdrop.fontSize == 12\n        assert backdrop.fontColor == \"\"\n        assert backdrop.color == \"\"\n        assert backdrop.comment == \"\"\n\n        # Add a non-backdrop node and check that its default values are not backdrop's ones\n        node = g.addNewNode(\"CopyFiles\")\n        assert node is not None\n        assert node.nodeWidth == 0\n        assert node.nodeHeight == 0\n        assert node.fontSize == 0\n        assert node.fontColor == \"\"\n        assert node.color == \"\"\n        assert node.comment == \"\"\n\n    def test_backdropNode_customAttributes(self):\n        \"\"\" Test that a backdrop node's attributes can be correctly updated. \"\"\"\n        g = Graph(\"Backdrop node with custom values\")\n        backdrop = g.addNewNode(\"Backdrop\")\n\n        # Set custom values for backdrop and assert the properties are correctly updated\n        width = backdrop.internalAttribute(\"nodeWidth\")\n        width.value = 400\n        assert backdrop.nodeWidth == 400\n\n        height = backdrop.internalAttribute(\"nodeHeight\")\n        height.value = 200\n        assert backdrop.nodeHeight == 200\n\n        fontSize = backdrop.internalAttribute(\"fontSize\")\n        fontSize.value = 10\n        assert backdrop.fontSize == 10\n\n        fontColor = backdrop.internalAttribute(\"fontColor\")\n        fontColor.value = \"#00FF00\"\n        assert backdrop.fontColor == \"#00FF00\"\n\n        color = backdrop.internalAttribute(\"color\")\n        color.value = \"#FF0000\"\n        assert backdrop.color == \"#FF0000\"\n\n        comment = backdrop.internalAttribute(\"comment\")\n        comment.value = \"hello world\"\n        assert backdrop.comment == \"hello world\"\n\n    def test_backdropNode_defaultSerialization(self):\n        \"\"\" Test that a backdrop node with default values is correctly serialized and deserialized. \"\"\"\n        g = Graph(\"Backdrop node default serialization\")\n        backdrop = g.addNewNode(\"Backdrop\")\n\n        # Save the graph in a file\n        graphFile = os.path.join(tempfile.mkdtemp(), \"test_backdrop_serialization.mg\")\n        g.save(graphFile)\n\n        # Reload the graph and check the values for the backdrop node are the default ones\n        g = loadGraph(graphFile)\n        backdrop = g.node(\"Backdrop_1\")\n        assert backdrop is not None\n        assert backdrop.nodeWidth == 600\n        assert backdrop.nodeHeight == 400\n        assert backdrop.fontSize == 12\n        assert backdrop.fontColor == \"\"\n        assert backdrop.color == \"\"\n        assert backdrop.comment == \"\"\n\n    def test_backdropNode_customSerialization(self):\n        \"\"\" Test that a backdrop node with custom values is correctly serialized and deserialized. \"\"\"\n        g = Graph(\"Backdrop node custom serialization\")\n        backdrop = g.addNewNode(\"Backdrop\")\n\n        # Set custom values for backdrop\n        width = backdrop.internalAttribute(\"nodeWidth\")\n        width.value = 400\n        height = backdrop.internalAttribute(\"nodeHeight\")\n        height.value = 200\n        fontSize = backdrop.internalAttribute(\"fontSize\")\n        fontSize.value = 10\n        fontColor = backdrop.internalAttribute(\"fontColor\")\n        fontColor.value = \"#00FF00\"\n        color = backdrop.internalAttribute(\"color\")\n        color.value = \"#FF0000\"\n        comment = backdrop.internalAttribute(\"comment\")\n        comment.value = \"hello world\"\n\n        # Save the graph in a file\n        graphFile = os.path.join(tempfile.mkdtemp(), \"test_backdrop_serialization.mg\")\n        g.save(graphFile)\n\n        # Reload the graph and check the values for the backdrop node are the default ones\n        g = loadGraph(graphFile)\n        backdrop = g.node(\"Backdrop_1\")\n        assert backdrop is not None\n        assert backdrop.nodeWidth == 400\n        assert backdrop.nodeHeight == 200\n        assert backdrop.fontSize == 10\n        assert backdrop.fontColor == \"#00FF00\"\n        assert backdrop.color == \"#FF0000\"\n        assert backdrop.comment == \"hello world\"\n\n\nclass TestResourceLevels:\n    \"\"\" Test that cpu, gpu, and ram descriptor attributes support both static Level values and callables. \"\"\"\n\n    def test_staticResourceLevels(self):\n        \"\"\" Test that static Level values are returned as-is. \"\"\"\n\n        class StaticLevelNode(desc.Node):\n            cpu = desc.Level.INTENSIVE\n            gpu = desc.Level.NONE\n            ram = desc.Level.EXTREME\n\n            inputs = []\n            outputs = []\n\n        with registeredNodeTypes([StaticLevelNode]):\n            g = Graph(\"\")\n            node = g.addNewNode(\"StaticLevelNode\")\n\n            assert node.cpu == desc.Level.INTENSIVE\n            assert node.gpu == desc.Level.NONE\n            assert node.ram == desc.Level.EXTREME\n\n    def test_callableResourceLevels(self):\n        \"\"\" Test that callable cpu/gpu/ram values are called with the node instance. \"\"\"\n\n        class CallableLevelNode(desc.Node):\n            cpu = lambda node: desc.Level.INTENSIVE if node.attribute(\"useMoreCpu\").value else desc.Level.NORMAL\n            gpu = lambda node: desc.Level.NORMAL if node.attribute(\"useGpu\").value else desc.Level.NONE\n            ram = lambda node: desc.Level.EXTREME if node.attribute(\"useMuchRam\").value else desc.Level.NORMAL\n\n            inputs = [\n                desc.BoolParam(name=\"useMoreCpu\", label=\"\", description=\"\", value=False, invalidate=False),\n                desc.BoolParam(name=\"useGpu\", label=\"\", description=\"\", value=False, invalidate=False),\n                desc.BoolParam(name=\"useMuchRam\", label=\"\", description=\"\", value=False, invalidate=False),\n            ]\n            outputs = []\n\n        with registeredNodeTypes([CallableLevelNode]):\n            g = Graph(\"\")\n            node = g.addNewNode(\"CallableLevelNode\")\n\n            # Default values: all False\n            assert node.cpu == desc.Level.NORMAL\n            assert node.gpu == desc.Level.NONE\n            assert node.ram == desc.Level.NORMAL\n\n            # Change attribute values\n            node.attribute(\"useMoreCpu\").value = True\n            assert node.cpu == desc.Level.INTENSIVE\n\n            node.attribute(\"useGpu\").value = True\n            assert node.gpu == desc.Level.NORMAL\n\n            node.attribute(\"useMuchRam\").value = True\n            assert node.ram == desc.Level.EXTREME\n\n    def test_mixedResourceLevels(self):\n        \"\"\" Test a node mixing static and callable resource level attributes. \"\"\"\n\n        class MixedLevelNode(desc.Node):\n            cpu = desc.Level.NORMAL  # static\n            gpu = lambda node: desc.Level.INTENSIVE if node.attribute(\"useGpu\").value else desc.Level.NONE  # callable\n            ram = desc.Level.EXTREME  # static\n\n            inputs = [\n                desc.BoolParam(name=\"useGpu\", label=\"\", description=\"\", value=False, invalidate=False),\n            ]\n            outputs = []\n\n        with registeredNodeTypes([MixedLevelNode]):\n            g = Graph(\"\")\n            node = g.addNewNode(\"MixedLevelNode\")\n\n            assert node.cpu == desc.Level.NORMAL\n            assert node.gpu == desc.Level.NONE\n            assert node.ram == desc.Level.EXTREME\n\n            node.attribute(\"useGpu\").value = True\n            assert node.gpu == desc.Level.INTENSIVE\n\n\nclass TestNodeColor:\n    \"\"\" Test that the color descriptor attribute can be defined on a node class and overridden. \"\"\"\n\n    def test_defaultColor(self):\n        \"\"\" Test that the default color for a node with no color defined is empty string. \"\"\"\n\n        class NoColorNode(desc.Node):\n            inputs = []\n            outputs = []\n\n        with registeredNodeTypes([NoColorNode]):\n            g = Graph(\"\")\n            node = g.addNewNode(\"NoColorNode\")\n\n            assert node.color == \"\"\n\n    def test_descriptorColor(self):\n        \"\"\" Test that a node class with a color defined returns that color when no instance color is set. \"\"\"\n\n        class ColoredNode(desc.Node):\n            color = \"#FF0000\"\n            inputs = []\n            outputs = []\n\n        with registeredNodeTypes([ColoredNode]):\n            g = Graph(\"\")\n            node = g.addNewNode(\"ColoredNode\")\n\n            # The node has no instance-specific color, so it should return the descriptor color\n            assert node.color == \"#FF0000\"\n\n    def test_instanceColorOverridesDescriptorColor(self):\n        \"\"\" Test that an instance-specific color overrides the descriptor color. \"\"\"\n\n        class ColoredNode2(desc.Node):\n            color = \"#FF0000\"\n            inputs = []\n            outputs = []\n\n        with registeredNodeTypes([ColoredNode2]):\n            g = Graph(\"\")\n            node = g.addNewNode(\"ColoredNode2\")\n\n            # Override with instance color\n            node.internalAttribute(\"color\").value = \"#00FF00\"\n            assert node.color == \"#00FF00\"\n\n    def test_resetToDefaultRestoresDescriptorColor(self):\n        \"\"\" Test that resetting the color attribute to its default restores the descriptor color. \"\"\"\n\n        class ColoredNode3(desc.Node):\n            color = \"#FF0000\"\n            inputs = []\n            outputs = []\n\n        with registeredNodeTypes([ColoredNode3]):\n            g = Graph(\"\")\n            node = g.addNewNode(\"ColoredNode3\")\n\n            # Set an instance color\n            node.internalAttribute(\"color\").value = \"#00FF00\"\n            assert node.color == \"#00FF00\"\n\n            # Resetting to default should restore the descriptor color\n            node.internalAttribute(\"color\").resetToDefaultValue()\n            assert node.color == \"#FF0000\"\n\n\nclass TestNodeSizeLambda:\n    \"\"\"Tests for the node size evaluation with single-argument lambda (`lambda node: ...`).\"\"\"\n\n    def test_size_lambda_single_arg(self):\n        \"\"\"size defined as `lambda node: ...` should be evaluated with the node instance.\"\"\"\n\n        class NodeWithLambdaSize(desc.Node):\n            inputs = [\n                desc.IntParam(\n                    name=\"sizeInput\",\n                    label=\"Size Input\",\n                    description=\"Defines the node size.\",\n                    value=5,\n                    range=(0, 100, 1),\n                ),\n            ]\n            outputs = []\n            size = lambda node: node.sizeInput.value\n\n        with registeredNodeTypes([NodeWithLambdaSize]):\n            g = Graph(\"\")\n            node = g.addNewNode(\"NodeWithLambdaSize\")\n\n            assert node.evaluateSize() == 5\n\n            node.sizeInput.value = 10\n            assert node.evaluateSize() == 10\n\n    def test_size_static_node_size(self):\n        \"\"\"size defined as StaticNodeSize should still be evaluated correctly.\"\"\"\n\n        class NodeWithStaticSize(desc.Node):\n            inputs = []\n            outputs = []\n            size = desc.StaticNodeSize(7)\n\n        with registeredNodeTypes([NodeWithStaticSize]):\n            g = Graph(\"\")\n            node = g.addNewNode(\"NodeWithStaticSize\")\n\n            assert node.evaluateSize() == 7\n\n    def test_size_dynamic_node_size(self):\n        \"\"\"size defined as DynamicNodeSize should return the value of the referenced IntParam.\"\"\"\n\n        class NodeWithDynamicSize(desc.Node):\n            inputs = [\n                desc.IntParam(\n                    name=\"count\",\n                    label=\"Count\",\n                    description=\"Number of items.\",\n                    value=4,\n                    range=(0, 100, 1),\n                ),\n            ]\n            outputs = []\n            size = desc.DynamicNodeSize(\"count\")\n\n        with registeredNodeTypes([NodeWithDynamicSize]):\n            g = Graph(\"\")\n            node = g.addNewNode(\"NodeWithDynamicSize\")\n\n            assert node.evaluateSize() == 4\n\n            node.count.value = 12\n            assert node.evaluateSize() == 12\n\n    def test_size_custom_function(self):\n        \"\"\"size defined as a named function should be called with the node instance.\"\"\"\n\n        def customSizeFunction(node):\n            return node.itemCount.value * 2\n\n        class NodeWithCustomFunctionSize(desc.Node):\n            inputs = [\n                desc.IntParam(\n                    name=\"itemCount\",\n                    label=\"Item Count\",\n                    description=\"Number of items.\",\n                    value=3,\n                    range=(0, 100, 1),\n                ),\n            ]\n            outputs = []\n            size = customSizeFunction\n\n        with registeredNodeTypes([NodeWithCustomFunctionSize]):\n            g = Graph(\"\")\n            node = g.addNewNode(\"NodeWithCustomFunctionSize\")\n\n            assert node.evaluateSize() == 6\n\n            node.itemCount.value = 5\n            assert node.evaluateSize() == 10\n\n    def test_size_custom_callable_class(self):\n        \"\"\"size defined as an instance of a class with __call__ should be called with the node instance.\"\"\"\n\n        class CustomSizeComputer:\n            def __call__(self, node):\n                return node.itemCount.value + 1\n\n        class NodeWithCustomCallableSize(desc.Node):\n            inputs = [\n                desc.IntParam(\n                    name=\"itemCount\",\n                    label=\"Item Count\",\n                    description=\"Number of items.\",\n                    value=7,\n                    range=(0, 100, 1),\n                ),\n            ]\n            outputs = []\n            size = CustomSizeComputer()\n\n        with registeredNodeTypes([NodeWithCustomCallableSize]):\n            g = Graph(\"\")\n            node = g.addNewNode(\"NodeWithCustomCallableSize\")\n\n            assert node.evaluateSize() == 8\n\n            node.itemCount.value = 9\n            assert node.evaluateSize() == 10\n"
  },
  {
    "path": "tests/test_pipeline.py",
    "content": "#!/usr/bin/env python\n# coding:utf-8\nimport os\nimport tempfile\n\nimport meshroom.multiview\nfrom meshroom.core.graph import loadGraph\nfrom meshroom.core.node import Node\n\n\ndef test_pipeline():\n    meshroom.core.initNodes()\n    meshroom.core.initPipelines()\n\n    graph1InputFiles = [\"/non/existing/file1\", \"/non/existing/file2\"]\n    graph1 = loadGraph(meshroom.core.pipelineTemplates[\"appendTextAndFiles\"])\n    graph1.name = \"graph1\"\n    graph1AppendText1 = graph1.node(\"AppendText_1\")\n    graph1AppendText1.input.value = graph1InputFiles[0]\n    graph1AppendText2 = graph1.node(\"AppendText_2\")\n    graph1AppendText2.input.value = graph1InputFiles[1]\n\n    assert graph1.findNode(\"AppendFiles\").input.value == graph1AppendText1.output.value\n    assert graph1.findNode(\"AppendFiles\").input2.value == graph1AppendText2.output.value\n    assert graph1.findNode(\"AppendFiles\").input3.value == graph1InputFiles[0]\n    assert graph1.findNode(\"AppendFiles\").input4.value == graph1InputFiles[1]\n\n    assert not graph1AppendText1.input.isDefault\n    assert graph1AppendText2.input.getPrimitiveValue() == graph1InputFiles[1]\n\n    graph2InputFiles = [\"/non/existing/file\", \"\"]\n    graph2 = loadGraph(meshroom.core.pipelineTemplates[\"appendTextAndFiles\"])\n    graph2.name = \"graph2\"\n    graph2AppendText1 = graph2.node(\"AppendText_1\")\n    graph2AppendText1.input.value = graph2InputFiles[0]\n    graph2AppendText2 = graph2.node(\"AppendText_2\")\n    graph2AppendText2.input.value = graph2InputFiles[1]\n\n    # Ensure that all output UIDs are different as the input is different:\n    # graph1 != graph2\n    for node in graph1.nodes:\n        otherNode = graph2.node(node.name)\n        for key, attr in node.attributes.items():\n            if attr.isOutput and attr.enabled:\n                otherAttr = otherNode.attribute(key)\n                assert attr.uid() != otherAttr.uid()\n\n    # Test serialization/deserialization on both graphs\n    for graph in [graph1, graph2]:\n        filename = tempfile.mktemp()\n        graph.save(filename)\n        loadedGraph = loadGraph(filename)\n        os.remove(filename)\n        # Check that all nodes have been properly de-serialized\n        #  - Same node set\n        assert sorted([n.name for n in loadedGraph.nodes]) == sorted([n.name for n in graph.nodes])\n        #  - No compatibility issues\n        assert all(isinstance(n, Node) for n in loadedGraph.nodes)\n        #  - Same UIDs for every node\n        assert sorted([n._uid for n in loadedGraph.nodes]) == sorted([n._uid for n in graph.nodes])\n\n    # Graph 2b, set with identical parameters as graph 2\n    graph2b = loadGraph(meshroom.core.pipelineTemplates[\"appendTextAndFiles\"])\n    graph2b.name = \"graph2b\"\n    graph2bAppendText1 = graph2b.node(\"AppendText_1\")\n    graph2bAppendText1.input.value = graph2InputFiles[0]\n    graph2bAppendText2 = graph2b.node(\"AppendText_2\")\n    graph2bAppendText2.input.value = graph2InputFiles[1]\n\n    # Ensure that graph2 == graph2b\n    nodes, edges = graph2.dfsOnFinish()\n    for node in nodes:\n        otherNode = graph2b.node(node.name)\n        for key, attr in node.attributes.items():\n            otherAttr = otherNode.attribute(key)\n            if attr.isOutput and attr.enabled:\n                assert attr.uid() == otherAttr.uid()\n            else:\n                assert attr.uid() == otherAttr.uid()\n"
  },
  {
    "path": "tests/test_plugins.py",
    "content": "# coding:utf-8\n\nfrom meshroom.core import pluginManager, loadClassesNodes\nfrom meshroom.core.plugins import NodePluginStatus, Plugin\nfrom .utils import overrideOsEnvironmentVariables, registeredPlugins\n\nfrom pathlib import Path\nimport os\nimport time\n\n\nclass TestPluginWithValidNodesOnly:\n    plugin = None\n\n    @classmethod\n    def setup_class(cls):\n        folder = os.path.join(os.path.dirname(__file__), \"plugins\", \"meshroom\")\n        package = \"pluginA\"\n        cls.plugin = Plugin(package, folder)\n        nodes = loadClassesNodes(folder, package)\n        for node in nodes:\n            cls.plugin.addNodePlugin(node)\n        pluginManager.addPlugin(cls.plugin)\n\n    @classmethod\n    def teardown_class(cls):\n        for node in cls.plugin.nodes.values():\n            pluginManager.unregisterNode(node)\n        pluginManager.removePlugin(cls.plugin)\n        cls.plugin = None\n\n    def test_loadedPlugin(self):\n        # Assert that there are loaded plugins, and that \"pluginA\" is one of them\n        assert len(pluginManager.getPlugins()) >= 1\n        plugin = pluginManager.getPlugin(\"pluginA\")\n        assert plugin == self.plugin\n        assert str(plugin.path) == os.path.join(os.path.dirname(__file__), \"plugins\", \"meshroom\")\n\n        # Assert that the nodes of pluginA have been successfully registered\n        assert len(pluginManager.getRegisteredNodePlugins()) >= 2\n        for nodeName, nodePlugin in plugin.nodes.items():\n            assert nodePlugin.status == NodePluginStatus.LOADED\n            assert pluginManager.isRegistered(nodeName)\n\n        # Assert the template has been loaded\n        assert len(plugin.templates) == 1\n        name = list(plugin.templates.keys())[0]\n        assert name == \"sharedTemplate\"\n        assert plugin.templates[name] == os.path.join(str(plugin.path), \"sharedTemplate.mg\")\n\n    def test_unloadPlugin(self):\n        plugin = pluginManager.getPlugin(\"pluginA\")\n        assert plugin == self.plugin\n\n        # Unload the plugin without unregistering the nodes\n        pluginManager.removePlugin(plugin, unregisterNodePlugins=False)\n\n        # Assert the plugin is not loaded anymore\n        assert pluginManager.getPlugin(plugin.name) is None\n\n        # Assert the nodes are still registered and belong to an unloaded plugin\n        for nodeName, nodePlugin in plugin.nodes.items():\n            assert nodePlugin.status == NodePluginStatus.LOADED\n            assert pluginManager.isRegistered(nodeName)\n            assert pluginManager.belongsToPlugin(nodeName) is None\n\n        # Re-add the plugin\n        pluginManager.addPlugin(plugin, registerNodePlugins=False)\n        assert pluginManager.getPlugin(plugin.name)\n\n        # Unload the plugin with a full unregistration of the nodes\n        pluginManager.removePlugin(plugin)\n\n        # Assert the plugin is not loaded anymore\n        assert pluginManager.getPlugin(plugin.name) is None\n\n        # Assert the nodes have been successfully unregistered\n        for nodeName, nodePlugin in plugin.nodes.items():\n            assert nodePlugin.status == NodePluginStatus.NOT_LOADED\n            assert not pluginManager.isRegistered(nodeName)\n\n        # Re-add the plugin and re-register the nodes\n        pluginManager.addPlugin(plugin)\n        assert pluginManager.getPlugin(plugin.name)\n        for nodeName, nodePlugin in plugin.nodes.items():\n            assert nodePlugin.status == NodePluginStatus.LOADED\n            assert pluginManager.isRegistered(nodeName)\n\n    def test_updateRegisteredNodes(self):\n        nbRegisteredNodes = len(pluginManager.getRegisteredNodePlugins())\n        plugin = pluginManager.getPlugin(\"pluginA\")\n        assert plugin == self.plugin\n        nodeA = pluginManager.getRegisteredNodePlugin(\"PluginANodeA\")\n        nodeAName = nodeA.nodeDescriptor.__name__\n\n        # Unregister a node\n        assert nodeA\n        pluginManager.unregisterNode(nodeA)\n\n        # Check that the node has been fully unregistered:\n        #   - its status is \"NOT_LOADED\"\n        #   - it is still part of pluginA\n        #   - it is not in the list of registered plugins anymore (and returns None when requested)\n        assert nodeA.status == NodePluginStatus.NOT_LOADED\n        assert plugin.containsNodePlugin(nodeAName)\n        assert nodeA.plugin == plugin\n\n        assert pluginManager.getRegisteredNodePlugin(nodeAName) is None\n        assert nodeAName not in pluginManager.getRegisteredNodePlugins()\n        assert len(pluginManager.getRegisteredNodePlugins()) == nbRegisteredNodes - 1\n\n        # Re-register the node\n        pluginManager.registerNode(nodeA)\n\n        assert nodeA.status == NodePluginStatus.LOADED\n        assert pluginManager.getRegisteredNodePlugin(nodeAName)\n        assert len(pluginManager.getRegisteredNodePlugins()) == nbRegisteredNodes\n\n\nclass TestPluginWithInvalidNodes:\n    plugin = None\n\n    @classmethod\n    def setup_class(cls):\n        folder = os.path.join(os.path.dirname(__file__), \"plugins\", \"meshroom\")\n        package = \"pluginB\"\n        cls.plugin = Plugin(package, folder)\n        nodes = loadClassesNodes(folder, package)\n        for node in nodes:\n            cls.plugin.addNodePlugin(node)\n        pluginManager.addPlugin(cls.plugin)\n\n    @classmethod\n    def teardown_class(cls):\n        for node in cls.plugin.nodes.values():\n            pluginManager.unregisterNode(node)\n        pluginManager.removePlugin(cls.plugin)\n        cls.plugin = None\n\n    def test_loadedPlugin(self):\n        # Assert that there are loaded plugins, and that \"pluginB\" is one of them\n        assert len(pluginManager.getPlugins()) >= 1\n        plugin = pluginManager.getPlugin(\"pluginB\")\n        assert plugin == self.plugin\n        assert str(plugin.path) == os.path.join(os.path.dirname(__file__), \"plugins\", \"meshroom\")\n\n        # Assert that PluginBNodeA is successfully registered\n        assert pluginManager.isRegistered(\"PluginBNodeA\")\n        assert plugin.nodes[\"PluginBNodeA\"].status == NodePluginStatus.LOADED\n        assert plugin.nodes[\"PluginBNodeA\"].plugin == plugin\n\n        # Assert that PluginBNodeB has not been registered (description error)\n        assert not pluginManager.isRegistered(\"PluginBNodeB\")\n        assert plugin.nodes[\"PluginBNodeB\"].status == NodePluginStatus.DESC_ERROR\n        assert plugin.nodes[\"PluginBNodeB\"].plugin == plugin\n\n        # Assert the template has been loaded\n        assert len(plugin.templates) == 1\n        name = list(plugin.templates.keys())[0]\n        assert name == \"sharedTemplate\"\n        assert plugin.templates[name] == os.path.join(str(plugin.path), \"sharedTemplate.mg\")\n\n    def test_reloadNodePluginInvalidDescrpition(self):\n        plugin = pluginManager.getPlugin(\"pluginB\")\n        assert plugin == self.plugin\n        node = plugin.nodes[\"PluginBNodeB\"]\n        nodeName = node.nodeDescriptor.__name__\n\n        # Check that the node has not been registered\n        assert node.status == NodePluginStatus.DESC_ERROR\n        assert not pluginManager.isRegistered(nodeName)\n\n        # Check that the node cannot be registered\n        pluginManager.registerNode(node)\n        assert not pluginManager.isRegistered(nodeName)\n\n        # Replace directly in the node file the line that fails the validation\n        # on the description with a line that will pass\n        originalFileContent = None\n        with open(node.path, \"r\") as f:\n            originalFileContent = f.read()\n\n        replaceFileContent = originalFileContent.replace('\"not an integer\"', '1')\n        with open(node.path, \"w\") as f:\n            f.write(replaceFileContent)\n\n        # Reload the node and assert it is valid\n        node.reload()\n        assert node.status == NodePluginStatus.NOT_LOADED\n\n        # Attempt to register node plugin\n        pluginManager.registerNode(node)\n        assert pluginManager.isRegistered(nodeName)\n\n        # Reload the node again without any change\n        node.reload()\n        assert pluginManager.isRegistered(nodeName)\n\n        # Hack to ensure that the timestamp of the file will be different after being rewritten\n        # Without it, on some systems, the operation is too fast and the timestamp does not change,\n        # cause the test to fail\n        time.sleep(0.1)\n\n        # Restore the node file to its original state (with a description error)\n        with open(node.path, \"w\") as f:\n            f.write(originalFileContent)\n\n        timestampOr2 = os.path.getmtime(node.path)\n        print(f\"New timestamp: {timestampOr2}\")\n        print(os.stat(node.path))\n\n        # Reload the node and assert it is invalid while still registered\n        node.reload()\n        assert node.status == NodePluginStatus.DESC_ERROR\n        assert pluginManager.isRegistered(nodeName)\n\n        # Unregister it\n        pluginManager.unregisterNode(node)\n        assert node.status == NodePluginStatus.DESC_ERROR  # Not NOT_LOADED\n        assert not pluginManager.isRegistered(nodeName)\n\n    def test_reloadNodePluginSyntaxError(self):\n        plugin = pluginManager.getPlugin(\"pluginB\")\n        assert plugin == self.plugin\n        node = plugin.nodes[\"PluginBNodeA\"]\n        nodeName = node.nodeDescriptor.__name__\n\n        # Check that the node has been registered\n        assert node.status == NodePluginStatus.LOADED\n        assert pluginManager.isRegistered(nodeName)\n\n        # Introduce a syntax error in the description\n        originalFileContent = None\n        with open(node.path, \"r\") as f:\n            originalFileContent = f.read()\n\n        replaceFileContent = originalFileContent.replace('name=\"input\",', 'name=\"input\"')\n        with open(node.path, \"w\") as f:\n            f.write(replaceFileContent)\n\n        # Reload the node and assert it is invalid but still registered\n        node.reload()\n        assert node.status == NodePluginStatus.DESC_ERROR\n        assert pluginManager.isRegistered(nodeName)\n\n        # Restore the node file to its original state (with a description error)\n        with open(node.path, \"w\") as f:\n            f.write(originalFileContent)\n\n        # Assert the status is correct and the node is still registered\n        node.reload()\n        assert node.status == NodePluginStatus.NOT_LOADED\n        assert pluginManager.isRegistered(nodeName)\n\n\nclass TestPluginsConfiguration:\n    CONFIG_PATH = (\"CONFIG_PATH\", \"sharedTemplate.mg\", \"config.json\")\n    ERRONEOUS_CONFIG_PATH = (\"ERRONEOUS_CONFIG_PATH\", \"erroneous_path\", \"not_erroneous_path\")\n    CONFIG_STRING = (\"CONFIG_STRING\", \"configFile\", \"notConfigFile\")\n\n    CONFIG_KEYS = [CONFIG_PATH[0], ERRONEOUS_CONFIG_PATH[0], CONFIG_STRING[0]]\n\n    def test_loadedConfig(self):\n        # Check that the config.json file for the plugins in the \"plugins\" directory is\n        # correctly loaded\n        folder = os.path.join(os.path.dirname(__file__), \"plugins\")\n        with registeredPlugins(folder):\n            plugin = pluginManager.getPlugin(\"pluginA\")\n            assert plugin\n\n            # Check that the config file has been properly loaded\n            config = plugin.configEnv\n            configFullEnv = plugin.configFullEnv\n            assert len(config) == 3, \"The configuration file contains exactly 3 keys.\"\n            assert len(configFullEnv) >= len(os.environ) and \\\n                len(configFullEnv) == len(os.environ) + len(config), \\\n                \"The configuration environment should have the same number of keys as \" \\\n                \"os.environ and the configuration file\"\n\n            # Check that all the keys have been properly read\n            assert list(config.keys()) == self.CONFIG_KEYS\n\n            # Check that the valid path has been correctly read, resolved and set\n            assert configFullEnv[self.CONFIG_PATH[0]] == config[self.CONFIG_PATH[0]]\n            assert configFullEnv[self.CONFIG_PATH[0]] == Path(\n                os.path.join(plugin.path, self.CONFIG_PATH[1])).resolve().as_posix()\n\n            # Check that the invalid path has been read, unresolved, and set\n            assert configFullEnv[self.ERRONEOUS_CONFIG_PATH[0]] == self.ERRONEOUS_CONFIG_PATH[1]\n            assert config[self.ERRONEOUS_CONFIG_PATH[0]] == self.ERRONEOUS_CONFIG_PATH[1]\n\n            # Check that the string has been correctly read and set\n            assert configFullEnv[self.CONFIG_STRING[0]] == self.CONFIG_STRING[1]\n            assert config[self.CONFIG_STRING[0]] == self.CONFIG_STRING[1]\n\n    def test_loadedConfigWithOnlyExistingKeys(self):\n        # Set the keys from the config file in the current environment\n        environment = {\n            self.CONFIG_PATH[0]: self.CONFIG_PATH[2],\n            self.ERRONEOUS_CONFIG_PATH[0]: self.ERRONEOUS_CONFIG_PATH[2],\n            self.CONFIG_STRING[0]: self.CONFIG_STRING[2]\n        }\n\n        folder = os.path.join(os.path.dirname(__file__), \"plugins\")\n        with (overrideOsEnvironmentVariables(environment), registeredPlugins(folder)):\n            plugin = pluginManager.getPlugin(\"pluginA\")\n            assert plugin\n\n            # Check that the config file has been properly loaded and read\n            # Environment variables that are already set should not have any effect on that\n            # reading of values\n            config = plugin.configEnv\n            assert len(config) == 3\n            assert list(config.keys()) == self.CONFIG_KEYS\n            assert config[self.CONFIG_PATH[0]] == Path(\n                os.path.join(plugin.path, self.CONFIG_PATH[1])).resolve().as_posix()\n            assert config[self.ERRONEOUS_CONFIG_PATH[0]] == self.ERRONEOUS_CONFIG_PATH[1]\n            assert config[self.CONFIG_STRING[0]] == self.CONFIG_STRING[1]\n\n            # Check that the values of the configuration file are not taking precedence over\n            # those in the environment\n            configFullEnv = plugin.configFullEnv\n            assert all(key in configFullEnv for key in config.keys())\n\n            assert config[self.CONFIG_PATH[0]] != self.CONFIG_PATH[2]\n            assert configFullEnv[self.CONFIG_PATH[0]] == self.CONFIG_PATH[2]\n\n            assert config[self.ERRONEOUS_CONFIG_PATH[0]] != self.ERRONEOUS_CONFIG_PATH[2]\n            assert configFullEnv[self.ERRONEOUS_CONFIG_PATH[0]] == self.ERRONEOUS_CONFIG_PATH[2]\n\n            assert config[self.CONFIG_STRING[0]] != self.CONFIG_STRING[2]\n            assert configFullEnv[self.CONFIG_STRING[0]] == self.CONFIG_STRING[2]\n\n    def test_loadedConfigWithSomeExistingKeys(self):\n        # Set some keys from the config file in the current environment\n        environment = {\n            self.ERRONEOUS_CONFIG_PATH[0]: self.ERRONEOUS_CONFIG_PATH[2],\n            self.CONFIG_STRING[0]: self.CONFIG_STRING[2]\n        }\n\n        folder = os.path.join(os.path.dirname(__file__), \"plugins\")\n        with (overrideOsEnvironmentVariables(environment), registeredPlugins(folder)):\n            plugin = pluginManager.getPlugin(\"pluginA\")\n            assert plugin\n\n            # Check that the config file has been properly loaded and read\n            # Environment variables that are already set should not have any effect on that\n            # reading of values\n            config = plugin.configEnv\n            assert len(config) == 3\n            assert list(config.keys()) == self.CONFIG_KEYS\n            assert config[self.CONFIG_PATH[0]] == Path(\n                os.path.join(plugin.path, self.CONFIG_PATH[1])).resolve().as_posix()\n            assert config[self.ERRONEOUS_CONFIG_PATH[0]] == self.ERRONEOUS_CONFIG_PATH[1]\n            assert config[self.CONFIG_STRING[0]] == self.CONFIG_STRING[1]\n\n            # Check that the values of the configuration file are not taking precedence over\n            # those in the environment\n            configFullEnv = plugin.configFullEnv\n            assert all(key in configFullEnv for key in config.keys())\n\n            assert config[self.CONFIG_PATH[0]] == Path(os.path.join(\n                plugin.path, self.CONFIG_PATH[1])).resolve().as_posix()\n            assert configFullEnv[self.CONFIG_PATH[0]] == Path(os.path.join(\n                plugin.path, self.CONFIG_PATH[1])).resolve().as_posix()\n\n            assert config[self.ERRONEOUS_CONFIG_PATH[0]] != self.ERRONEOUS_CONFIG_PATH[2]\n            assert configFullEnv[self.ERRONEOUS_CONFIG_PATH[0]] == self.ERRONEOUS_CONFIG_PATH[2]\n\n            assert config[self.CONFIG_STRING[0]] != self.CONFIG_STRING[2]\n            assert configFullEnv[self.CONFIG_STRING[0]] == self.CONFIG_STRING[2]\n"
  },
  {
    "path": "tests/test_submit.py",
    "content": "# coding:utf-8\n\n\"\"\"\nThis test aims to replicate toe process on node submission\n\"\"\"\n\nimport os\nimport time\nfrom sys import platform\n\nfrom .utils import registerNodeDesc\n\nimport meshroom\nfrom meshroom.core import pluginManager, loadClassesNodes, loadSubmitters, registerSubmitter, meshroomFolder\nfrom meshroom.core.graph import Graph\nfrom meshroom.core.plugins import Plugin\nfrom meshroom.core.node import Node, Status\nfrom meshroom.core.submitter import jobManager\nfrom meshroom.submitters.localFarmSubmitter import LocalFarmSubmitter, LocalFarmJob\n\nfrom localfarm.localFarmLauncher import FarmLauncher\n\n\nIS_LINUX = (platform == \"linux\" or platform == \"linux2\")\n\n\ndef get_submitter() -> LocalFarmSubmitter:\n    for sName, s in meshroom.core.submitters.items():\n        if sName == \"LocalFarm\":\n            return s\n    raise RuntimeError(\"LocalFarm submitter not found\")\n\n\ndef getJobEnv():\n    \"\"\" Required to have meshroom recognize plugins that were created here \"\"\"\n    pluginFolder = os.path.join(os.path.dirname(__file__), \"plugins\")\n    return {\n        \"MESHROOM_PLUGINS_PATH\": pluginFolder\n    }\n\n\ndef waitForNodeCompletion(job: LocalFarmJob, node: Node, timeout=25):\n    \"\"\"\n    Wait for a node to complete processing\n    \"\"\"\n    print(f\"Waiting for node {node.name} to complete...\")\n    startTime = time.time()\n    while True:\n        node.updateStatusFromCache()\n        nodeStatus = node.getGlobalStatus()\n        if nodeStatus not in (Status.SUBMITTED, Status.RUNNING):\n            print(f\"Node status switched to {nodeStatus}\")\n            return\n        # Check for job error\n        err = job.getJobErrors()\n        if err:\n            raise RuntimeError(f\"Job encountered an error: {err}\")\n        if time.time() - startTime > timeout:\n            raise TimeoutError(f\"Node {node.name} did not complete within {timeout} seconds\")\n        time.sleep(1)\n\ndef processSubmit(node: Node, graph, tmp_path):\n    \"\"\"\n    Actual function that test the submit process\n    \"\"\"\n    # Save graph\n    tmp_path = str(tmp_path)\n    graph.save(os.path.join(tmp_path, \"graph.mg\"))\n    # Prepare all chunks\n    node.initStatusOnSubmit()\n    # Start farm\n    farmLauncher = FarmLauncher(tmp_path)\n    farmLauncher.start()\n    time.sleep(1)\n    error = None\n    try:\n        print(f\"submit {node}\")\n        submitter = get_submitter()\n        submitter.setFarmPath(tmp_path)\n        submitter.setJobEnv(getJobEnv())\n        nodesToProcess, edgesToProcess = [node], []\n        # Update nodes status\n        for node in nodesToProcess:\n            node.initStatusOnSubmit()\n        # Update monitored to make sure meshroom knows when task status change \n        graph.updateMonitoredFiles()\n        assert node.getGlobalStatus() == Status.SUBMITTED\n        res = submitter.submit(nodesToProcess, edgesToProcess, graph.filepath, submitLabel=\"TestSubmit\")\n        assert res is not None, \"Submitter returned no job\"\n        assert res.__class__.__name__ == \"LocalFarmJob\", \"Submitted job is not a LocalFarmJob\"\n        jobManager.addJob(res, nodesToProcess)\n        waitForNodeCompletion(res, node)\n    except Exception as e:\n        error = e\n    finally:\n        farmLauncher.stop()\n    if error:\n        raise error\n    else:\n        farmLauncher.clean()\n\n\nclass TestNodeSubmit:\n    __test__ = IS_LINUX\n\n    @classmethod\n    def setup_class(cls):\n        # meshroom.core.initSubmitters()\n        submitters = loadSubmitters(meshroomFolder, \"submitters\")\n        for submitter in submitters:\n            registerSubmitter(submitter())\n\n        cls.folder = os.path.join(os.path.dirname(__file__), \"plugins\", \"meshroom\")\n        package = \"pluginSubmitter\"\n        cls.plugin = Plugin(package, cls.folder)\n        nodes = loadClassesNodes(cls.folder, package)\n        for node in nodes:\n            cls.plugin.addNodePlugin(node)\n        pluginManager.addPlugin(cls.plugin)\n\n    @classmethod\n    def teardown_class(cls):\n        for node in cls.plugin.nodes.values():\n            pluginManager.unregisterNode(node)\n        pluginManager.removePlugin(cls.plugin)\n        cls.plugin = None\n\n    def setupNode(self, graph, name):\n        plugin = pluginManager.getPlugin(\"pluginSubmitter\")\n        node = plugin.nodes[name]\n        nodeType = node.nodeDescriptor\n        registerNodeDesc(nodeType)\n        node = graph.addNewNode(nodeType.__name__)\n        return node\n\n    def test_submitNoParallel(self, tmp_path):\n        graph = Graph(\"\")\n        graph._cacheDir = os.path.join(tmp_path, \"cache\")\n        node = self.setupNode(graph, \"PluginSubmitterA\")\n        # Submit\n        processSubmit(node, graph, tmp_path)\n\n    def test_submitStaticSize(self, tmp_path):\n        graph = Graph(\"\")\n        graph._cacheDir = os.path.join(tmp_path, \"cache\")\n        node = self.setupNode(graph, \"PluginSubmitterB\")\n        # Submit\n        processSubmit(node, graph, tmp_path)\n\n    def test_submitDynamicSize(self, tmp_path):\n        graph = Graph(\"\")\n        graph._cacheDir = os.path.join(tmp_path, \"cache\")\n        node = self.setupNode(graph, \"PluginSubmitterC\")\n        # Submit\n        processSubmit(node, graph, tmp_path)\n"
  },
  {
    "path": "tests/utils.py",
    "content": "from contextlib import contextmanager\nfrom unittest.mock import patch\n\nimport meshroom\nfrom meshroom.core import desc, pluginManager, loadPluginFolder\nfrom meshroom.core.plugins import NodePlugin, NodePluginStatus\n\nimport os\n\n@contextmanager\ndef registeredNodeTypes(nodeTypes: list[desc.Node]):\n    nodePluginsList = {}\n    for nodeType in nodeTypes:\n        nodePlugin = NodePlugin(nodeType)\n        pluginManager.registerNode(nodePlugin)\n        nodePluginsList[nodeType] = nodePlugin\n\n    yield\n\n    for nodeType in nodeTypes:\n        pluginManager.unregisterNode(nodePluginsList[nodeType])\n\n\n@contextmanager\ndef overrideNodeTypeVersion(nodeType: desc.Node, version: str):\n    \"\"\" Helper context manager to override the version of a given node type. \"\"\"\n    unpatchedFunc = meshroom.core.nodeVersion\n    with patch.object(\n        meshroom.core,\n        \"nodeVersion\",\n        side_effect=lambda type: version if type is nodeType else unpatchedFunc(type),\n    ):\n        yield\n\n\ndef registerNodeDesc(nodeDesc: desc.Node):\n    name = nodeDesc.__name__\n    if not pluginManager.isRegistered(name):\n        pluginManager._nodePlugins[name] = NodePlugin(nodeDesc)\n\n\ndef unregisterNodeDesc(nodeDesc: desc.Node):\n    name = nodeDesc.__name__\n    if pluginManager.isRegistered(name):\n        del pluginManager._nodePlugins[name]\n\n\n@contextmanager\ndef registeredPlugins(folder: str):\n    plugins = loadPluginFolder(folder)\n\n    yield\n\n    for plugin in plugins:\n        pluginManager.removePlugin(plugin)\n\n@contextmanager\ndef overrideOsEnvironmentVariables(envVariables: dict):\n    with patch.dict(os.environ, envVariables, clear=False):\n        yield\n"
  }
]