p4gcc 0.0.1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
p4gcc-0.0.1/LICENSE.md ADDED
@@ -0,0 +1,7 @@
1
+ Copyright 2026, The copyright holders according to COPYING.md
2
+
3
+ Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
4
+
5
+ The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
6
+
7
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
p4gcc-0.0.1/PKG-INFO ADDED
@@ -0,0 +1,83 @@
1
+ Metadata-Version: 2.4
2
+ Name: p4gcc
3
+ Version: 0.0.1
4
+ Summary: Run py4dgeo change analysis from CloudCompare
5
+ Maintainer-email: Dominic Kempf <dominic.kempf@iwr.uni-heidelberg.de>
6
+ License-Expression: MIT
7
+ Classifier: Programming Language :: Python :: 3
8
+ Classifier: Operating System :: OS Independent
9
+ Requires-Python: >=3.10
10
+ Description-Content-Type: text/markdown
11
+ License-File: LICENSE.md
12
+ Requires-Dist: py4dgeo>=1.2.1
13
+ Provides-Extra: tests
14
+ Requires-Dist: pytest; extra == "tests"
15
+ Requires-Dist: pytest-cov; extra == "tests"
16
+ Provides-Extra: docs
17
+ Requires-Dist: sphinx; extra == "docs"
18
+ Requires-Dist: sphinx_mdinclude; extra == "docs"
19
+ Requires-Dist: sphinx_rtd_theme; extra == "docs"
20
+ Dynamic: license-file
21
+
22
+ # Welcome to py4dgeo-cloudcompare
23
+
24
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
25
+ [![GitHub Workflow Status](https://img.shields.io/github/actions/workflow/status/dokempf/py4dgeo-cloudcompare/ci.yml?branch=main)](https://github.com/dokempf/py4dgeo-cloudcompare/actions/workflows/ci.yml)
26
+ [![Documentation Status](https://readthedocs.org/projects/py4dgeo-cloudcompare/badge/)](https://py4dgeo-cloudcompare.readthedocs.io/)
27
+ [![codecov](https://codecov.io/gh/dokempf/py4dgeo-cloudcompare/branch/main/graph/badge.svg)](https://codecov.io/gh/dokempf/py4dgeo-cloudcompare)
28
+
29
+ ## Installation
30
+
31
+ The Python package `py4dgeo_cloudcompare` can be installed from PyPI:
32
+
33
+ ```
34
+ python -m pip install py4dgeo_cloudcompare
35
+ ```
36
+
37
+ ## Development installation
38
+
39
+ If you want to contribute to the development of `py4dgeo_cloudcompare`, we recommend
40
+ the following editable installation from this repository:
41
+
42
+ ```
43
+ git clone git@github.com:dokempf/py4dgeo-cloudcompare.git
44
+ cd py4dgeo-cloudcompare
45
+ python -m pip install --editable .[tests]
46
+ ```
47
+
48
+ Having done so, the test suite can be run using `pytest`:
49
+
50
+ ```
51
+ python -m pytest
52
+ ```
53
+
54
+ ## CloudCompare plugin
55
+
56
+ This package provides a Python M3C2 plugin for
57
+ [CloudCompare-PythonRuntime](https://tmontaigu.github.io/CloudCompare-PythonRuntime/getting_started.html#plugin).
58
+ Install the package into the Python environment used by CloudCompare:
59
+
60
+ ```console
61
+ python -m pip install .
62
+ ```
63
+
64
+ After restarting CloudCompare, run
65
+ **Compute M3C2 (py4dgeo)** from the Python action launcher or the Python
66
+ plugins menu. The modeless dialog can be opened before any point clouds are
67
+ loaded. Its pickers update while clouds are loaded into CloudCompare and let
68
+ you assign the reference, compared, and core point clouds. An existing
69
+ selection is used only to choose convenient defaults. The dialog also
70
+ configures normal radii, projection radius and depth,
71
+ registration error, normal orientation, and robust aggregation.
72
+
73
+ The result is added to CloudCompare as a new point cloud at the core-point
74
+ locations. It displays the signed M3C2 distance and also contains scalar
75
+ fields for the level of detection, cloud spreads and sample counts, computed
76
+ normal components, and selected normal radius. CloudCompare global shifts and
77
+ scales are respected when clouds use different local coordinate systems.
78
+ CloudCompare discovers the plugin through the `cloudcompare.plugins` package
79
+ entry point declared in `pyproject.toml`.
80
+
81
+ ## Acknowledgments
82
+
83
+ This repository was set up using the [SSC Cookiecutter for Python Packages](https://github.com/ssciwr/cookiecutter-python-package).
p4gcc-0.0.1/README.md ADDED
@@ -0,0 +1,62 @@
1
+ # Welcome to py4dgeo-cloudcompare
2
+
3
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
4
+ [![GitHub Workflow Status](https://img.shields.io/github/actions/workflow/status/dokempf/py4dgeo-cloudcompare/ci.yml?branch=main)](https://github.com/dokempf/py4dgeo-cloudcompare/actions/workflows/ci.yml)
5
+ [![Documentation Status](https://readthedocs.org/projects/py4dgeo-cloudcompare/badge/)](https://py4dgeo-cloudcompare.readthedocs.io/)
6
+ [![codecov](https://codecov.io/gh/dokempf/py4dgeo-cloudcompare/branch/main/graph/badge.svg)](https://codecov.io/gh/dokempf/py4dgeo-cloudcompare)
7
+
8
+ ## Installation
9
+
10
+ The Python package `py4dgeo_cloudcompare` can be installed from PyPI:
11
+
12
+ ```
13
+ python -m pip install py4dgeo_cloudcompare
14
+ ```
15
+
16
+ ## Development installation
17
+
18
+ If you want to contribute to the development of `py4dgeo_cloudcompare`, we recommend
19
+ the following editable installation from this repository:
20
+
21
+ ```
22
+ git clone git@github.com:dokempf/py4dgeo-cloudcompare.git
23
+ cd py4dgeo-cloudcompare
24
+ python -m pip install --editable .[tests]
25
+ ```
26
+
27
+ Having done so, the test suite can be run using `pytest`:
28
+
29
+ ```
30
+ python -m pytest
31
+ ```
32
+
33
+ ## CloudCompare plugin
34
+
35
+ This package provides a Python M3C2 plugin for
36
+ [CloudCompare-PythonRuntime](https://tmontaigu.github.io/CloudCompare-PythonRuntime/getting_started.html#plugin).
37
+ Install the package into the Python environment used by CloudCompare:
38
+
39
+ ```console
40
+ python -m pip install .
41
+ ```
42
+
43
+ After restarting CloudCompare, run
44
+ **Compute M3C2 (py4dgeo)** from the Python action launcher or the Python
45
+ plugins menu. The modeless dialog can be opened before any point clouds are
46
+ loaded. Its pickers update while clouds are loaded into CloudCompare and let
47
+ you assign the reference, compared, and core point clouds. An existing
48
+ selection is used only to choose convenient defaults. The dialog also
49
+ configures normal radii, projection radius and depth,
50
+ registration error, normal orientation, and robust aggregation.
51
+
52
+ The result is added to CloudCompare as a new point cloud at the core-point
53
+ locations. It displays the signed M3C2 distance and also contains scalar
54
+ fields for the level of detection, cloud spreads and sample counts, computed
55
+ normal components, and selected normal radius. CloudCompare global shifts and
56
+ scales are respected when clouds use different local coordinate systems.
57
+ CloudCompare discovers the plugin through the `cloudcompare.plugins` package
58
+ entry point declared in `pyproject.toml`.
59
+
60
+ ## Acknowledgments
61
+
62
+ This repository was set up using the [SSC Cookiecutter for Python Packages](https://github.com/ssciwr/cookiecutter-python-package).
@@ -0,0 +1,59 @@
1
+ # This section describes the requirements of the build/installation
2
+ # process itself. Being able to do this was the original reason to
3
+ # introduce pyproject.toml
4
+ [build-system]
5
+ requires = [
6
+ "setuptools >=61",
7
+ ]
8
+ build-backend = "setuptools.build_meta"
9
+
10
+ # This section provides general project metadata that is used across
11
+ # a variety of build tools. Notably, the version specified here is the
12
+ # single source of truth for py4dgeo_cloudcompare's version
13
+ [project]
14
+ name = "p4gcc"
15
+ description = "Run py4dgeo change analysis from CloudCompare"
16
+ readme = "README.md"
17
+ maintainers = [
18
+ { name = "Dominic Kempf", email = "dominic.kempf@iwr.uni-heidelberg.de" },
19
+ ]
20
+ version = "0.0.1"
21
+ requires-python = ">=3.10"
22
+ license-files = ["LICEN[CS]E*"]
23
+ license = "MIT"
24
+ classifiers = [
25
+ "Programming Language :: Python :: 3",
26
+ "Operating System :: OS Independent",
27
+ ]
28
+ dependencies = [
29
+ "py4dgeo>=1.2.1",
30
+ ]
31
+
32
+ [project.entry-points."cloudcompare.plugins"]
33
+ hello = "py4dgeo_cloudcompare.plugin:HelloWorldPlugin"
34
+ m3c2 = "py4dgeo_cloudcompare.m3c2:M3C2Plugin"
35
+ qt_versions = "py4dgeo_cloudcompare.qt_versions:QtVersionsPlugin"
36
+
37
+ [project.optional-dependencies]
38
+ tests = [
39
+ "pytest",
40
+ "pytest-cov",
41
+ ]
42
+ docs = [
43
+ "sphinx",
44
+ "sphinx_mdinclude",
45
+ "sphinx_rtd_theme",
46
+ ]
47
+
48
+
49
+ # The following section contains setuptools-specific configuration
50
+ # options. For a full reference of available options, check the overview
51
+ # at https://setuptools.pypa.io/en/latest/userguide/pyproject_config.html
52
+ [tool.setuptools.packages.find]
53
+ where = ["src"]
54
+
55
+ # The following is the configuration for the pytest test suite
56
+ [tool.pytest.ini_options]
57
+ testpaths = [
58
+ "tests",
59
+ ]
p4gcc-0.0.1/setup.cfg ADDED
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,83 @@
1
+ Metadata-Version: 2.4
2
+ Name: p4gcc
3
+ Version: 0.0.1
4
+ Summary: Run py4dgeo change analysis from CloudCompare
5
+ Maintainer-email: Dominic Kempf <dominic.kempf@iwr.uni-heidelberg.de>
6
+ License-Expression: MIT
7
+ Classifier: Programming Language :: Python :: 3
8
+ Classifier: Operating System :: OS Independent
9
+ Requires-Python: >=3.10
10
+ Description-Content-Type: text/markdown
11
+ License-File: LICENSE.md
12
+ Requires-Dist: py4dgeo>=1.2.1
13
+ Provides-Extra: tests
14
+ Requires-Dist: pytest; extra == "tests"
15
+ Requires-Dist: pytest-cov; extra == "tests"
16
+ Provides-Extra: docs
17
+ Requires-Dist: sphinx; extra == "docs"
18
+ Requires-Dist: sphinx_mdinclude; extra == "docs"
19
+ Requires-Dist: sphinx_rtd_theme; extra == "docs"
20
+ Dynamic: license-file
21
+
22
+ # Welcome to py4dgeo-cloudcompare
23
+
24
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
25
+ [![GitHub Workflow Status](https://img.shields.io/github/actions/workflow/status/dokempf/py4dgeo-cloudcompare/ci.yml?branch=main)](https://github.com/dokempf/py4dgeo-cloudcompare/actions/workflows/ci.yml)
26
+ [![Documentation Status](https://readthedocs.org/projects/py4dgeo-cloudcompare/badge/)](https://py4dgeo-cloudcompare.readthedocs.io/)
27
+ [![codecov](https://codecov.io/gh/dokempf/py4dgeo-cloudcompare/branch/main/graph/badge.svg)](https://codecov.io/gh/dokempf/py4dgeo-cloudcompare)
28
+
29
+ ## Installation
30
+
31
+ The Python package `py4dgeo_cloudcompare` can be installed from PyPI:
32
+
33
+ ```
34
+ python -m pip install py4dgeo_cloudcompare
35
+ ```
36
+
37
+ ## Development installation
38
+
39
+ If you want to contribute to the development of `py4dgeo_cloudcompare`, we recommend
40
+ the following editable installation from this repository:
41
+
42
+ ```
43
+ git clone git@github.com:dokempf/py4dgeo-cloudcompare.git
44
+ cd py4dgeo-cloudcompare
45
+ python -m pip install --editable .[tests]
46
+ ```
47
+
48
+ Having done so, the test suite can be run using `pytest`:
49
+
50
+ ```
51
+ python -m pytest
52
+ ```
53
+
54
+ ## CloudCompare plugin
55
+
56
+ This package provides a Python M3C2 plugin for
57
+ [CloudCompare-PythonRuntime](https://tmontaigu.github.io/CloudCompare-PythonRuntime/getting_started.html#plugin).
58
+ Install the package into the Python environment used by CloudCompare:
59
+
60
+ ```console
61
+ python -m pip install .
62
+ ```
63
+
64
+ After restarting CloudCompare, run
65
+ **Compute M3C2 (py4dgeo)** from the Python action launcher or the Python
66
+ plugins menu. The modeless dialog can be opened before any point clouds are
67
+ loaded. Its pickers update while clouds are loaded into CloudCompare and let
68
+ you assign the reference, compared, and core point clouds. An existing
69
+ selection is used only to choose convenient defaults. The dialog also
70
+ configures normal radii, projection radius and depth,
71
+ registration error, normal orientation, and robust aggregation.
72
+
73
+ The result is added to CloudCompare as a new point cloud at the core-point
74
+ locations. It displays the signed M3C2 distance and also contains scalar
75
+ fields for the level of detection, cloud spreads and sample counts, computed
76
+ normal components, and selected normal radius. CloudCompare global shifts and
77
+ scales are respected when clouds use different local coordinate systems.
78
+ CloudCompare discovers the plugin through the `cloudcompare.plugins` package
79
+ entry point declared in `pyproject.toml`.
80
+
81
+ ## Acknowledgments
82
+
83
+ This repository was set up using the [SSC Cookiecutter for Python Packages](https://github.com/ssciwr/cookiecutter-python-package).
@@ -0,0 +1,17 @@
1
+ LICENSE.md
2
+ README.md
3
+ pyproject.toml
4
+ src/p4gcc.egg-info/PKG-INFO
5
+ src/p4gcc.egg-info/SOURCES.txt
6
+ src/p4gcc.egg-info/dependency_links.txt
7
+ src/p4gcc.egg-info/entry_points.txt
8
+ src/p4gcc.egg-info/requires.txt
9
+ src/p4gcc.egg-info/top_level.txt
10
+ src/py4dgeo_cloudcompare/__init__.py
11
+ src/py4dgeo_cloudcompare/m3c2.py
12
+ src/py4dgeo_cloudcompare/plugin.py
13
+ src/py4dgeo_cloudcompare/qt_versions.py
14
+ tests/test_m3c2.py
15
+ tests/test_plugin.py
16
+ tests/test_py4dgeo_cloudcompare.py
17
+ tests/test_qt_versions.py
@@ -0,0 +1,4 @@
1
+ [cloudcompare.plugins]
2
+ hello = py4dgeo_cloudcompare.plugin:HelloWorldPlugin
3
+ m3c2 = py4dgeo_cloudcompare.m3c2:M3C2Plugin
4
+ qt_versions = py4dgeo_cloudcompare.qt_versions:QtVersionsPlugin
@@ -0,0 +1,10 @@
1
+ py4dgeo>=1.2.1
2
+
3
+ [docs]
4
+ sphinx
5
+ sphinx_mdinclude
6
+ sphinx_rtd_theme
7
+
8
+ [tests]
9
+ pytest
10
+ pytest-cov
@@ -0,0 +1 @@
1
+ py4dgeo_cloudcompare
@@ -0,0 +1,13 @@
1
+ from importlib import metadata
2
+
3
+ __version__ = metadata.version(__package__)
4
+ del metadata
5
+
6
+
7
+ def add_one(x: int):
8
+ """An example function that increases a number
9
+
10
+ :param x: The input parameter to increase
11
+ :return: The successor of the given number
12
+ """
13
+ return x + 1
@@ -0,0 +1,424 @@
1
+ """CloudCompare front end for :class:`py4dgeo.M3C2`.
2
+
3
+ The imports of py4dgeo and PySide6 deliberately happen when the action is
4
+ executed. CloudCompare can therefore discover the plugin even while its
5
+ Python environment is still being configured, and the numerical parts of
6
+ this module can be tested without a Qt application.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from dataclasses import dataclass
12
+ from typing import Any, Sequence
13
+
14
+ import numpy as np
15
+ import pycc
16
+
17
+
18
+ @dataclass(frozen=True)
19
+ class M3C2Parameters:
20
+ """Parameters exposed by the M3C2 dialog, in py4dgeo units."""
21
+
22
+ normal_radii: tuple[float, ...] = (0.5, 1.0, 2.0)
23
+ cylinder_radius: float = 1.0
24
+ max_distance: float = 0.0
25
+ registration_error: float = 0.0
26
+ orientation_vector: tuple[float, float, float] = (0.0, 0.0, 1.0)
27
+ robust_aggregation: bool = False
28
+
29
+
30
+ @dataclass(frozen=True)
31
+ class M3C2Result:
32
+ """Numerical result used to construct a CloudCompare point cloud."""
33
+
34
+ distances: np.ndarray
35
+ uncertainties: np.ndarray
36
+ normals: np.ndarray
37
+ normal_radii: np.ndarray
38
+
39
+
40
+ def parse_normal_radii(value: str) -> tuple[float, ...]:
41
+ """Parse a comma/semicolon/whitespace separated list of positive radii."""
42
+
43
+ tokens = value.replace(";", " ").replace(",", " ").split()
44
+ if not tokens:
45
+ raise ValueError("Specify at least one normal radius.")
46
+ radii = tuple(float(token) for token in tokens)
47
+ if any(not np.isfinite(radius) or radius <= 0.0 for radius in radii):
48
+ raise ValueError("Normal radii must be finite and greater than zero.")
49
+ return radii
50
+
51
+
52
+ def calculate_m3c2(
53
+ reference_points: np.ndarray,
54
+ compared_points: np.ndarray,
55
+ core_points: np.ndarray,
56
+ parameters: M3C2Parameters,
57
+ ) -> M3C2Result:
58
+ """Run py4dgeo M3C2 on three ``(n, 3)`` coordinate arrays."""
59
+
60
+ from py4dgeo import Epoch, M3C2
61
+
62
+ reference = Epoch(_points_array(reference_points, "reference cloud"))
63
+ compared = Epoch(_points_array(compared_points, "compared cloud"))
64
+ core = _points_array(core_points, "core points")
65
+
66
+ orientation = np.asarray(parameters.orientation_vector, dtype=np.float64)
67
+ if orientation.shape != (3,) or not np.isfinite(orientation).all():
68
+ raise ValueError("The orientation vector must contain three finite values.")
69
+ if np.linalg.norm(orientation) == 0.0:
70
+ raise ValueError("The orientation vector must not be zero.")
71
+ if parameters.cylinder_radius <= 0.0:
72
+ raise ValueError("The projection cylinder radius must be greater than zero.")
73
+ if not parameters.normal_radii or any(
74
+ not np.isfinite(radius) or radius <= 0.0 for radius in parameters.normal_radii
75
+ ):
76
+ raise ValueError("Normal radii must be finite and greater than zero.")
77
+ if parameters.max_distance < 0.0 or parameters.registration_error < 0.0:
78
+ raise ValueError("Maximum depth and registration error cannot be negative.")
79
+
80
+ algorithm = M3C2(
81
+ epochs=(reference, compared),
82
+ corepoints=core,
83
+ cyl_radius=float(parameters.cylinder_radius),
84
+ normal_radii=list(parameters.normal_radii),
85
+ max_distance=float(parameters.max_distance),
86
+ registration_error=float(parameters.registration_error),
87
+ orientation_vector=orientation,
88
+ robust_aggr=parameters.robust_aggregation,
89
+ )
90
+ distances, uncertainties = algorithm.run()
91
+ return M3C2Result(
92
+ distances=np.asarray(distances),
93
+ uncertainties=uncertainties,
94
+ normals=np.asarray(algorithm.directions()),
95
+ normal_radii=np.asarray(algorithm.directions_radii()).reshape(-1),
96
+ )
97
+
98
+
99
+ def _points_array(points: np.ndarray, description: str) -> np.ndarray:
100
+ array = np.ascontiguousarray(points, dtype=np.float64)
101
+ if array.ndim != 2 or array.shape[1] != 3 or array.shape[0] == 0:
102
+ raise ValueError(f"The {description} must be a non-empty (n, 3) array.")
103
+ if not np.isfinite(array).all():
104
+ raise ValueError(f"The {description} contains non-finite coordinates.")
105
+ return array
106
+
107
+
108
+ def _cloud_name(cloud: Any) -> str:
109
+ return str(cloud.getName())
110
+
111
+
112
+ def _global_points(cloud: Any) -> np.ndarray:
113
+ """Return CloudCompare local points in their global coordinate system."""
114
+
115
+ points = np.asarray(cloud.points(), dtype=np.float64).copy()
116
+ shift_value = cloud.getGlobalShift()
117
+ shift = np.array([shift_value.x, shift_value.y, shift_value.z])
118
+ scale = float(cloud.getGlobalScale())
119
+ if scale == 0.0:
120
+ raise ValueError(f"Cloud '{_cloud_name(cloud)}' has a zero global scale.")
121
+ # CloudCompare stores local = (global + shift) * scale.
122
+ return points / scale - shift
123
+
124
+
125
+ def _loaded_point_clouds(app: Any) -> list[Any]:
126
+ """Collect point clouds recursively from CloudCompare's database tree."""
127
+
128
+ root = app.dbRootObject()
129
+ if root is None:
130
+ return []
131
+
132
+ clouds: list[Any] = []
133
+ pending = [root]
134
+ while pending:
135
+ entity = pending.pop()
136
+ if isinstance(entity, pycc.ccPointCloud):
137
+ clouds.append(entity)
138
+ pending.extend(
139
+ entity.getChild(index)
140
+ for index in reversed(range(entity.getChildrenNumber()))
141
+ )
142
+ return clouds
143
+
144
+
145
+ def _preferred_cloud_indices(app: Any, clouds: Sequence[Any]) -> tuple[int, int]:
146
+ """Use the current selection as defaults without requiring a selection."""
147
+
148
+ selected_ids = {
149
+ entity.getUniqueID()
150
+ for entity in app.getSelectedEntities()
151
+ if isinstance(entity, pycc.ccPointCloud)
152
+ }
153
+ selected_indices = [
154
+ index
155
+ for index, cloud in enumerate(clouds)
156
+ if cloud.getUniqueID() in selected_ids
157
+ ]
158
+ if len(selected_indices) >= 2:
159
+ return selected_indices[0], selected_indices[1]
160
+ if len(selected_indices) == 1:
161
+ other = 0 if selected_indices[0] != 0 else 1
162
+ return selected_indices[0], other
163
+ return 0, 1
164
+
165
+
166
+ def create_result_cloud(
167
+ core_cloud: Any,
168
+ result: M3C2Result,
169
+ name: str,
170
+ ) -> Any:
171
+ """Create a display-ready CloudCompare cloud with M3C2 scalar fields."""
172
+
173
+ local_core_points = np.asarray(core_cloud.points(), dtype=pycc.PointCoordinateType)
174
+ output = pycc.ccPointCloud(
175
+ np.ascontiguousarray(local_core_points[:, 0]),
176
+ np.ascontiguousarray(local_core_points[:, 1]),
177
+ np.ascontiguousarray(local_core_points[:, 2]),
178
+ )
179
+ output.setName(name)
180
+
181
+ shift = core_cloud.getGlobalShift()
182
+ output.setGlobalShift(shift.x, shift.y, shift.z)
183
+ output.setGlobalScale(core_cloud.getGlobalScale())
184
+
185
+ fields = (
186
+ ("M3C2 distance", result.distances),
187
+ ("M3C2 LoD", result.uncertainties["lodetection"]),
188
+ ("M3C2 spread cloud 1", result.uncertainties["spread1"]),
189
+ ("M3C2 spread cloud 2", result.uncertainties["spread2"]),
190
+ ("M3C2 samples cloud 1", result.uncertainties["num_samples1"]),
191
+ ("M3C2 samples cloud 2", result.uncertainties["num_samples2"]),
192
+ ("M3C2 normal X", result.normals[:, 0]),
193
+ ("M3C2 normal Y", result.normals[:, 1]),
194
+ ("M3C2 normal Z", result.normals[:, 2]),
195
+ ("M3C2 normal radius", result.normal_radii),
196
+ )
197
+ distance_index = -1
198
+ for field_name, values in fields:
199
+ values = np.asarray(values, dtype=pycc.ScalarType).reshape(-1)
200
+ if values.size != local_core_points.shape[0]:
201
+ raise ValueError(f"Result field '{field_name}' has an unexpected size.")
202
+ index = output.addScalarField(field_name, values)
203
+ output.getScalarField(index).computeMinAndMax()
204
+ if field_name == "M3C2 distance":
205
+ distance_index = index
206
+
207
+ output.setCurrentDisplayedScalarField(distance_index)
208
+ output.showSF(True)
209
+ output.showSFColorsScale(True)
210
+ return output
211
+
212
+
213
+ class _M3C2Dialog:
214
+ """Small wrapper that keeps Qt imports local to GUI execution."""
215
+
216
+ def __init__(self, app: Any):
217
+ from PySide6 import QtCore, QtWidgets
218
+
219
+ self.QtWidgets = QtWidgets
220
+ self.app = app
221
+ self.clouds: list[Any] = []
222
+ self.dialog = QtWidgets.QDialog(QtWidgets.QApplication.activeWindow())
223
+ self.dialog.setWindowTitle("py4dgeo M3C2")
224
+ self.dialog.setMinimumWidth(480)
225
+ layout = QtWidgets.QVBoxLayout(self.dialog)
226
+ description = QtWidgets.QLabel(
227
+ "Compute signed M3C2 distances for selected CloudCompare point clouds."
228
+ )
229
+ description.setWordWrap(True)
230
+ layout.addWidget(description)
231
+ form = QtWidgets.QFormLayout()
232
+ layout.addLayout(form)
233
+
234
+ self.reference = QtWidgets.QComboBox()
235
+ self.compared = QtWidgets.QComboBox()
236
+ self.core = QtWidgets.QComboBox()
237
+ form.addRow("Reference cloud", self.reference)
238
+ form.addRow("Compared cloud", self.compared)
239
+ form.addRow("Core points", self.core)
240
+ self.cloud_status = QtWidgets.QLabel()
241
+ self.refresh_button = QtWidgets.QPushButton("Refresh point clouds")
242
+ self.refresh_button.clicked.connect(self.refresh_clouds)
243
+ cloud_controls = QtWidgets.QHBoxLayout()
244
+ cloud_controls.addWidget(self.cloud_status)
245
+ cloud_controls.addStretch()
246
+ cloud_controls.addWidget(self.refresh_button)
247
+ form.addRow("", cloud_controls)
248
+
249
+ self.normal_radii = QtWidgets.QLineEdit("0.5, 1.0, 2.0")
250
+ self.normal_radii.setToolTip("One or more py4dgeo search radii, separated by commas")
251
+ form.addRow("Normal radii", self.normal_radii)
252
+ self.cylinder_radius = self._spin(1.0, 1.0e-9)
253
+ form.addRow("Projection cylinder radius", self.cylinder_radius)
254
+ self.max_distance = self._spin(0.0, 0.0)
255
+ self.max_distance.setSpecialValueText("Automatic")
256
+ form.addRow("Maximum projection depth", self.max_distance)
257
+ self.registration_error = self._spin(0.0, 0.0)
258
+ form.addRow("Registration error", self.registration_error)
259
+
260
+ orientation = QtWidgets.QHBoxLayout()
261
+ self.orientation = []
262
+ for label, initial in zip("XYZ", (0.0, 0.0, 1.0)):
263
+ orientation.addWidget(QtWidgets.QLabel(label))
264
+ widget = self._spin(initial, -1.0e12)
265
+ widget.setMaximum(1.0e12)
266
+ self.orientation.append(widget)
267
+ orientation.addWidget(widget)
268
+ form.addRow("Normal orientation vector", orientation)
269
+ self.robust = QtWidgets.QCheckBox("Use median and IQR")
270
+ form.addRow("Robust aggregation", self.robust)
271
+ self.output_name = QtWidgets.QLineEdit("M3C2 result")
272
+ form.addRow("Output cloud name", self.output_name)
273
+
274
+ buttons = QtWidgets.QDialogButtonBox(
275
+ QtWidgets.QDialogButtonBox.StandardButton.Ok
276
+ | QtWidgets.QDialogButtonBox.StandardButton.Cancel
277
+ )
278
+ buttons.button(QtWidgets.QDialogButtonBox.StandardButton.Ok).setText("Compute")
279
+ buttons.accepted.connect(self._validate_and_accept)
280
+ buttons.rejected.connect(self.dialog.reject)
281
+ layout.addWidget(buttons)
282
+ self._align_center = QtCore.Qt.AlignmentFlag.AlignCenter
283
+ description.setAlignment(self._align_center)
284
+
285
+ # Keep the picker synchronized while the modeless dialog is open. This
286
+ # lets users load point clouds after launching the plugin.
287
+ self._refresh_timer = QtCore.QTimer(self.dialog)
288
+ self._refresh_timer.setInterval(1000)
289
+ self._refresh_timer.timeout.connect(self.refresh_clouds)
290
+ self._refresh_timer.start()
291
+ self.refresh_clouds(use_selection_defaults=True)
292
+
293
+ @staticmethod
294
+ def _cloud_label(cloud: Any) -> str:
295
+ return f"{_cloud_name(cloud)} [ID {cloud.getUniqueID()}]"
296
+
297
+ @staticmethod
298
+ def _cloud_id(cloud: Any) -> Any:
299
+ return cloud.getUniqueID()
300
+
301
+ def refresh_clouds(self, use_selection_defaults: bool = False) -> None:
302
+ """Refresh the pickers while preserving the current assignments."""
303
+
304
+ combos = (self.reference, self.compared, self.core)
305
+ selected_ids = []
306
+ for combo in combos:
307
+ index = combo.currentIndex()
308
+ selected_ids.append(
309
+ self._cloud_id(self.clouds[index])
310
+ if 0 <= index < len(self.clouds)
311
+ else None
312
+ )
313
+
314
+ clouds = _loaded_point_clouds(self.app)
315
+ cloud_ids = [self._cloud_id(cloud) for cloud in clouds]
316
+ if cloud_ids == [self._cloud_id(cloud) for cloud in self.clouds]:
317
+ if len(clouds) == 0:
318
+ self.cloud_status.setText("No point clouds loaded")
319
+ return
320
+
321
+ self.clouds = clouds
322
+ labels = [self._cloud_label(cloud) for cloud in clouds]
323
+ if use_selection_defaults and len(clouds) >= 2:
324
+ reference_index, compared_index = _preferred_cloud_indices(self.app, clouds)
325
+ default_indices = (reference_index, compared_index, reference_index)
326
+ else:
327
+ default_indices = (0, 1, 0)
328
+
329
+ for combo, selected_id, default_index in zip(
330
+ combos, selected_ids, default_indices
331
+ ):
332
+ combo.clear()
333
+ combo.addItems(labels)
334
+ if selected_id in cloud_ids:
335
+ combo.setCurrentIndex(cloud_ids.index(selected_id))
336
+ elif default_index < len(clouds):
337
+ combo.setCurrentIndex(default_index)
338
+
339
+ if len(clouds) == 0:
340
+ self.cloud_status.setText("No point clouds loaded")
341
+ elif len(clouds) == 1:
342
+ self.cloud_status.setText("Load one more point cloud")
343
+ else:
344
+ self.cloud_status.setText(f"{len(clouds)} point clouds available")
345
+
346
+ def _spin(self, value: float, minimum: float):
347
+ widget = self.QtWidgets.QDoubleSpinBox()
348
+ widget.setDecimals(6)
349
+ widget.setRange(minimum, 1.0e12)
350
+ widget.setValue(value)
351
+ return widget
352
+
353
+ def _validate_and_accept(self) -> None:
354
+ self.refresh_clouds()
355
+ if len(self.clouds) < 2:
356
+ raise ValueError(
357
+ "Load at least two point clouds in CloudCompare before computing."
358
+ )
359
+ parameters = self.parameters()
360
+ if np.linalg.norm(parameters.orientation_vector) == 0.0:
361
+ raise ValueError("The normal orientation vector must not be zero.")
362
+ if self.reference.currentIndex() == self.compared.currentIndex():
363
+ raise ValueError("Reference and compared clouds must be different.")
364
+ if not self.output_name.text().strip():
365
+ raise ValueError("Specify a name for the output cloud.")
366
+ self.dialog.accept()
367
+
368
+ def show(self) -> None:
369
+ self.refresh_clouds()
370
+ self.dialog.show()
371
+ self.dialog.raise_()
372
+ self.dialog.activateWindow()
373
+
374
+ def parameters(self) -> M3C2Parameters:
375
+ return M3C2Parameters(
376
+ normal_radii=parse_normal_radii(self.normal_radii.text()),
377
+ cylinder_radius=self.cylinder_radius.value(),
378
+ max_distance=self.max_distance.value(),
379
+ registration_error=self.registration_error.value(),
380
+ orientation_vector=tuple(widget.value() for widget in self.orientation),
381
+ robust_aggregation=self.robust.isChecked(),
382
+ )
383
+
384
+
385
+ class M3C2Plugin(pycc.PythonPluginInterface):
386
+ """CloudCompare PythonRuntime plugin exposing py4dgeo M3C2."""
387
+
388
+ def __init__(self) -> None:
389
+ super().__init__()
390
+ self.app = pycc.GetInstance()
391
+ self._dialog: _M3C2Dialog | None = None
392
+
393
+ def getActions(self):
394
+ return [pycc.Action(name="Compute M3C2 (py4dgeo)", target=self.run)]
395
+
396
+ def run(self) -> None:
397
+ if self._dialog is None:
398
+ self._dialog = _M3C2Dialog(self.app)
399
+ self._dialog.dialog.accepted.connect(self._compute)
400
+ self._dialog.show()
401
+
402
+ def _compute(self) -> None:
403
+ """Compute from the assignments captured by the modeless dialog."""
404
+
405
+ assert self._dialog is not None
406
+ dialog = self._dialog
407
+ clouds = dialog.clouds
408
+ reference = clouds[dialog.reference.currentIndex()]
409
+ compared = clouds[dialog.compared.currentIndex()]
410
+ core = clouds[dialog.core.currentIndex()]
411
+ result = calculate_m3c2(
412
+ _global_points(reference),
413
+ _global_points(compared),
414
+ _global_points(core),
415
+ dialog.parameters(),
416
+ )
417
+ output = create_result_cloud(core, result, dialog.output_name.text().strip())
418
+ self.app.addToDB(output, updateZoom=False)
419
+ self.app.redrawAll()
420
+ print(
421
+ f"py4dgeo M3C2: added '{dialog.output_name.text().strip()}' "
422
+ f"with {output.size()} core points"
423
+ )
424
+
@@ -0,0 +1,18 @@
1
+ """Minimal CloudCompare Python plugin."""
2
+
3
+ import pycc
4
+
5
+
6
+ def hello_world() -> None:
7
+ """Print a greeting in CloudCompare's Python console."""
8
+ print("Hello from Python")
9
+
10
+
11
+ class HelloWorldPlugin(pycc.PythonPluginInterface):
12
+ """Expose a Hello World action in CloudCompare."""
13
+
14
+ def __init__(self) -> None:
15
+ super().__init__()
16
+
17
+ def getActions(self):
18
+ return [pycc.Action(name="Hello World", target=hello_world)]
@@ -0,0 +1,20 @@
1
+ """CloudCompare plugin for reporting the compile-time and runtime Qt versions."""
2
+
3
+ import pycc
4
+
5
+
6
+ def report_qt_versions() -> None:
7
+ """Print the Qt versions used to build and run CloudCompare."""
8
+
9
+ print(f"Qt compile-time version: {pycc.QT_VERSION_STR}")
10
+ print(f"Qt runtime version: {pycc.qtVersion()}")
11
+
12
+
13
+ class QtVersionsPlugin(pycc.PythonPluginInterface):
14
+ """Expose Qt version information as a separate CloudCompare action."""
15
+
16
+ def __init__(self) -> None:
17
+ super().__init__()
18
+
19
+ def getActions(self):
20
+ return [pycc.Action(name="Report Qt Versions", target=report_qt_versions)]
@@ -0,0 +1,241 @@
1
+ import importlib
2
+ import sys
3
+ from types import ModuleType
4
+
5
+ import numpy as np
6
+ import pytest
7
+
8
+
9
+ class PythonPluginInterface:
10
+ pass
11
+
12
+
13
+ class Action:
14
+ def __init__(self, *, name, target, icon=None):
15
+ self.name = name
16
+ self.target = target
17
+ self.icon = icon
18
+
19
+
20
+ class PointCloud:
21
+ pass
22
+
23
+
24
+ @pytest.fixture
25
+ def m3c2_module(monkeypatch):
26
+ pycc = ModuleType("pycc")
27
+ pycc.PythonPluginInterface = PythonPluginInterface
28
+ pycc.Action = Action
29
+ pycc.ccPointCloud = PointCloud
30
+ pycc.PointCoordinateType = np.float32
31
+ pycc.ScalarType = np.float32
32
+ pycc.GetInstance = lambda: None
33
+ monkeypatch.setitem(sys.modules, "pycc", pycc)
34
+ sys.modules.pop("py4dgeo_cloudcompare.m3c2", None)
35
+ return importlib.import_module("py4dgeo_cloudcompare.m3c2")
36
+
37
+
38
+ def test_parse_normal_radii(m3c2_module):
39
+ assert m3c2_module.parse_normal_radii("0.5, 1; 2") == (0.5, 1.0, 2.0)
40
+
41
+ for invalid in ("", "one", "0", "-1", "nan"):
42
+ with pytest.raises(ValueError):
43
+ m3c2_module.parse_normal_radii(invalid)
44
+
45
+
46
+ def test_global_points_applies_cloudcompare_shift_and_scale(m3c2_module):
47
+ class Shift:
48
+ x, y, z = (-100.0, 50.0, -2.0)
49
+
50
+ class Cloud:
51
+ def points(self):
52
+ return np.array([[2.0, 4.0, 6.0]], dtype=np.float32)
53
+
54
+ def getGlobalShift(self):
55
+ return Shift()
56
+
57
+ def getGlobalScale(self):
58
+ return 2.0
59
+
60
+ # global = local / scale - shift
61
+ np.testing.assert_allclose(
62
+ m3c2_module._global_points(Cloud()), [[101.0, -48.0, 5.0]]
63
+ )
64
+
65
+
66
+ def test_loaded_point_clouds_walks_database_tree(m3c2_module):
67
+ class Group:
68
+ def __init__(self, *children):
69
+ self.children = children
70
+
71
+ def getChildrenNumber(self):
72
+ return len(self.children)
73
+
74
+ def getChild(self, index):
75
+ return self.children[index]
76
+
77
+ cloud1 = PointCloud()
78
+ cloud1.getChildrenNumber = lambda: 0
79
+ cloud2 = PointCloud()
80
+ cloud2.getChildrenNumber = lambda: 0
81
+ root = Group(Group(cloud1), cloud2)
82
+ app = type("App", (), {"dbRootObject": lambda self: root})()
83
+
84
+ assert m3c2_module._loaded_point_clouds(app) == [cloud1, cloud2]
85
+
86
+
87
+ def test_selection_only_controls_cloud_defaults(m3c2_module):
88
+ clouds = [PointCloud(), PointCloud(), PointCloud()]
89
+ for identifier, cloud in enumerate(clouds, start=10):
90
+ cloud.getUniqueID = lambda identifier=identifier: identifier
91
+
92
+ app = type(
93
+ "App",
94
+ (),
95
+ {"getSelectedEntities": lambda self: [clouds[2], clouds[0]]},
96
+ )()
97
+ assert m3c2_module._preferred_cloud_indices(app, clouds) == (0, 2)
98
+
99
+
100
+ def test_calculate_m3c2_maps_parameters(monkeypatch, m3c2_module):
101
+ py4dgeo = ModuleType("py4dgeo")
102
+
103
+ class Epoch:
104
+ def __init__(self, points):
105
+ self.points = points
106
+
107
+ class M3C2:
108
+ last_kwargs = None
109
+
110
+ def __init__(self, **kwargs):
111
+ M3C2.last_kwargs = kwargs
112
+ self.count = len(kwargs["corepoints"])
113
+
114
+ def run(self):
115
+ dtype = [
116
+ ("lodetection", float),
117
+ ("spread1", float),
118
+ ("spread2", float),
119
+ ("num_samples1", int),
120
+ ("num_samples2", int),
121
+ ]
122
+ return np.arange(self.count), np.zeros(self.count, dtype=dtype)
123
+
124
+ def directions(self):
125
+ return np.tile([0.0, 0.0, 1.0], (self.count, 1))
126
+
127
+ def directions_radii(self):
128
+ return np.full((self.count, 1), 2.0)
129
+
130
+ py4dgeo.Epoch = Epoch
131
+ py4dgeo.M3C2 = M3C2
132
+ monkeypatch.setitem(sys.modules, "py4dgeo", py4dgeo)
133
+
134
+ points = np.array([[0, 0, 0], [1, 1, 1]], dtype=np.float32)
135
+ parameters = m3c2_module.M3C2Parameters(
136
+ normal_radii=(1.0, 2.0),
137
+ cylinder_radius=3.0,
138
+ max_distance=4.0,
139
+ registration_error=0.2,
140
+ orientation_vector=(0.0, 1.0, 0.0),
141
+ robust_aggregation=True,
142
+ )
143
+ result = m3c2_module.calculate_m3c2(points, points, points, parameters)
144
+
145
+ assert M3C2.last_kwargs["cyl_radius"] == 3.0
146
+ assert M3C2.last_kwargs["normal_radii"] == [1.0, 2.0]
147
+ assert M3C2.last_kwargs["max_distance"] == 4.0
148
+ assert M3C2.last_kwargs["registration_error"] == 0.2
149
+ assert M3C2.last_kwargs["robust_aggr"] is True
150
+ assert result.distances.tolist() == [0, 1]
151
+ assert result.normal_radii.tolist() == [2.0, 2.0]
152
+
153
+
154
+ def test_plugin_action(m3c2_module):
155
+ plugin = m3c2_module.M3C2Plugin()
156
+ actions = plugin.getActions()
157
+ assert len(actions) == 1
158
+ assert actions[0].name == "Compute M3C2 (py4dgeo)"
159
+
160
+
161
+ def test_plugin_opens_dialog_without_loaded_clouds(monkeypatch, m3c2_module):
162
+ shown = []
163
+
164
+ class Signal:
165
+ def connect(self, callback):
166
+ self.callback = callback
167
+
168
+ class Dialog:
169
+ def __init__(self, app):
170
+ self.app = app
171
+ self.dialog = type("Widget", (), {"accepted": Signal()})()
172
+
173
+ def show(self):
174
+ shown.append(True)
175
+
176
+ monkeypatch.setattr(m3c2_module, "_M3C2Dialog", Dialog)
177
+ plugin = m3c2_module.M3C2Plugin()
178
+ plugin.run()
179
+ assert shown == [True]
180
+
181
+
182
+ def test_plugin_run_propagates_dialog_exception(monkeypatch, m3c2_module):
183
+ error = RuntimeError("dialog failed")
184
+
185
+ class Dialog:
186
+ def __init__(self, app):
187
+ raise error
188
+
189
+ monkeypatch.setattr(m3c2_module, "_M3C2Dialog", Dialog)
190
+ plugin = m3c2_module.M3C2Plugin()
191
+
192
+ with pytest.raises(RuntimeError) as caught:
193
+ plugin.run()
194
+
195
+ assert caught.value is error
196
+
197
+
198
+ def test_dialog_validation_propagates_exception(m3c2_module):
199
+ dialog = type(
200
+ "Dialog",
201
+ (),
202
+ {
203
+ "clouds": [],
204
+ "refresh_clouds": lambda self: None,
205
+ },
206
+ )()
207
+
208
+ with pytest.raises(ValueError, match="Load at least two point clouds"):
209
+ m3c2_module._M3C2Dialog._validate_and_accept(dialog)
210
+
211
+
212
+ def test_plugin_compute_propagates_raw_exception(monkeypatch, m3c2_module):
213
+ error = RuntimeError("M3C2 failed")
214
+ cloud = object()
215
+ combo = type("Combo", (), {"currentIndex": lambda self: 0})()
216
+ output_name = type("Name", (), {"text": lambda self: "result"})()
217
+ dialog = type(
218
+ "Dialog",
219
+ (),
220
+ {
221
+ "clouds": [cloud],
222
+ "reference": combo,
223
+ "compared": combo,
224
+ "core": combo,
225
+ "output_name": output_name,
226
+ "parameters": lambda self: m3c2_module.M3C2Parameters(),
227
+ },
228
+ )()
229
+ monkeypatch.setattr(m3c2_module, "_global_points", lambda cloud: np.empty((1, 3)))
230
+
231
+ def fail(*args):
232
+ raise error
233
+
234
+ monkeypatch.setattr(m3c2_module, "calculate_m3c2", fail)
235
+ plugin = m3c2_module.M3C2Plugin()
236
+ plugin._dialog = dialog
237
+
238
+ with pytest.raises(RuntimeError) as caught:
239
+ plugin._compute()
240
+
241
+ assert caught.value is error
@@ -0,0 +1,31 @@
1
+ import importlib
2
+ import sys
3
+ from types import ModuleType
4
+
5
+
6
+ class PythonPluginInterface:
7
+ pass
8
+
9
+
10
+ class Action:
11
+ def __init__(self, *, name, target, icon=None):
12
+ self.name = name
13
+ self.target = target
14
+ self.icon = icon
15
+
16
+
17
+ def test_hello_world_plugin(monkeypatch, capsys):
18
+ pycc = ModuleType("pycc")
19
+ pycc.PythonPluginInterface = PythonPluginInterface
20
+ pycc.Action = Action
21
+ monkeypatch.setitem(sys.modules, "pycc", pycc)
22
+
23
+ plugin_module = importlib.import_module("py4dgeo_cloudcompare.plugin")
24
+ plugin = plugin_module.HelloWorldPlugin()
25
+ actions = plugin.getActions()
26
+
27
+ assert len(actions) == 1
28
+ assert actions[0].name == "Hello World"
29
+
30
+ actions[0].target()
31
+ assert capsys.readouterr().out == "Hello from Python\n"
@@ -0,0 +1,5 @@
1
+ import py4dgeo_cloudcompare
2
+
3
+
4
+ def test_py4dgeo_cloudcompare():
5
+ assert py4dgeo_cloudcompare.add_one(1) == 2
@@ -0,0 +1,38 @@
1
+ import importlib
2
+ import sys
3
+ from types import ModuleType
4
+
5
+
6
+ class PythonPluginInterface:
7
+ pass
8
+
9
+
10
+ class Action:
11
+ def __init__(self, *, name, target, icon=None):
12
+ self.name = name
13
+ self.target = target
14
+ self.icon = icon
15
+
16
+
17
+ def test_qt_versions_plugin_reports_compile_time_and_runtime_versions(
18
+ monkeypatch, capsys
19
+ ):
20
+ pycc = ModuleType("pycc")
21
+ pycc.PythonPluginInterface = PythonPluginInterface
22
+ pycc.Action = Action
23
+ pycc.QT_VERSION_STR = "6.8.3"
24
+ pycc.qtVersion = lambda: "6.8.2"
25
+ monkeypatch.setitem(sys.modules, "pycc", pycc)
26
+ sys.modules.pop("py4dgeo_cloudcompare.qt_versions", None)
27
+
28
+ plugin_module = importlib.import_module("py4dgeo_cloudcompare.qt_versions")
29
+ actions = plugin_module.QtVersionsPlugin().getActions()
30
+
31
+ assert len(actions) == 1
32
+ assert actions[0].name == "Report Qt Versions"
33
+
34
+ actions[0].target()
35
+ assert capsys.readouterr().out == (
36
+ "Qt compile-time version: 6.8.3\n"
37
+ "Qt runtime version: 6.8.2\n"
38
+ )