diff --git a/surface_apps-assets/uijson/surface_normals.ui.json b/surface_apps-assets/uijson/surface_normals.ui.json new file mode 100644 index 0000000..a7c8e3e --- /dev/null +++ b/surface_apps-assets/uijson/surface_normals.ui.json @@ -0,0 +1,31 @@ +{ + "version": "0.1.0", + "title": "Compute normals", + "conda_environment": "surface_apps", + "run_command": "surface_apps.surface_normals.driver", + "geoh5": "", + "monitoring_directory": "", + "workspace_geoh5": "", + "conda_environment_boolean": false, + "surfaces": { + "group": "Data", + "main": true, + "meshType": "{f26feba3-aded-494b-b9e9-b2bbcbe298e1}", + "label": "Surfaces", + "value": "", + "multiSelect": true + }, + "merge_points": { + "group": "Output", + "main": true, + "label": "Merge", + "value": true + }, + "out_name": { + "group": "Output", + "main": true, + "label": "Name", + "value": "surface normals", + "tooltip": "Name of the output points object." + } +} diff --git a/surface_apps/driver.py b/surface_apps/driver.py index f94ca0d..29f06f3 100644 --- a/surface_apps/driver.py +++ b/surface_apps/driver.py @@ -10,8 +10,10 @@ from __future__ import annotations import logging +import sys import tempfile from abc import abstractmethod +from json import load from pathlib import Path from geoapps_utils.driver.data import BaseData @@ -99,6 +101,17 @@ def params(self, val: BaseData): raise TypeError("Parameters must be a BaseData subclass.") self._params = val + @classmethod + def start(cls, filepath: str | Path, driver_class=None, **kwargs): + with open(filepath, encoding="utf-8") as jsonfile: + uijson = load(jsonfile) + + if driver_class is None: + module = __import__(uijson["run_command"], fromlist=["Driver"]) + driver_class = module.Driver + + super().start(filepath, driver_class=driver_class, **kwargs) + def add_ui_json(self, entity: ObjectBase | UIJsonGroup) -> None: """ Add ui.json file to entity. @@ -111,3 +124,8 @@ def add_ui_json(self, entity: ObjectBase | UIJsonGroup) -> None: self.params.write_ui_json(filepath) entity.add_file(str(filepath)) + + +if __name__ == "__main__": + file = Path(sys.argv[1]).resolve() + BaseSurfaceDriver.start(file) diff --git a/surface_apps/iso_surfaces/driver.py b/surface_apps/iso_surfaces/driver.py index 8945af7..b0c46f8 100644 --- a/surface_apps/iso_surfaces/driver.py +++ b/surface_apps/iso_surfaces/driver.py @@ -11,6 +11,7 @@ import logging import sys +from pathlib import Path import numpy as np from geoapps_utils.utils.formatters import string_name @@ -27,14 +28,14 @@ logger = logging.getLogger(__name__) -class IsoSurfacesDriver(BaseSurfaceDriver): +class Driver(BaseSurfaceDriver): """ Driver for the detection of iso-surfaces within geoh5py objects. :param parameters: Application parameters. """ - _parameter_class = IsoSurfaceParameters + _params_class = IsoSurfaceParameters def __init__(self, parameters: IsoSurfaceParameters | InputFile): super().__init__(parameters) @@ -109,7 +110,5 @@ def iso_surface( if __name__ == "__main__": - file = sys.argv[1] - ifile = InputFile.read_ui_json(file) - driver = IsoSurfacesDriver(ifile) - driver.run() + file = Path(sys.argv[1]).resolve() + Driver.start(file) diff --git a/surface_apps/surface_normals/__init__.py b/surface_apps/surface_normals/__init__.py new file mode 100644 index 0000000..396c659 --- /dev/null +++ b/surface_apps/surface_normals/__init__.py @@ -0,0 +1,8 @@ +# '''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''' +# Copyright (c) 2024-2025 Mira Geoscience Ltd. ' +# ' +# This file is part of surface-apps package. ' +# ' +# surface-apps is distributed under the terms and conditions of the MIT License +# (see LICENSE file at the root of this source code package). ' +# '''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''' diff --git a/surface_apps/surface_normals/driver.py b/surface_apps/surface_normals/driver.py new file mode 100644 index 0000000..99a67b4 --- /dev/null +++ b/surface_apps/surface_normals/driver.py @@ -0,0 +1,77 @@ +# '''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''' +# Copyright (c) 2024-2025 Mira Geoscience Ltd. ' +# ' +# This file is part of surface-apps package. ' +# ' +# surface-apps is distributed under the terms and conditions of the MIT License +# (see LICENSE file at the root of this source code package). ' +# '''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''' + +import logging +import sys +from pathlib import Path + +from geoapps_utils.driver.driver import BaseDriver +from geoapps_utils.utils.transformations import ( + compute_normals, +) +from geoh5py.groups.property_group_type import GroupTypeEnum +from geoh5py.shared.conversion.base import CellObjectConversion +from geoh5py.shared.merging.points import PointsMerger +from geoh5py.shared.utils import fetch_active_workspace + +from surface_apps.surface_normals.options import SurfaceNormalsOptions + + +logger = logging.getLogger(__name__) + + +class Driver(BaseDriver): + """ + Driver for computing surface normals. + + :param options: Application options + """ + + _params_class = SurfaceNormalsOptions + + def run(self): + with fetch_active_workspace(self.params.geoh5, mode="r+") as geoh5: + points = [] + for surface in self.params.surfaces: + normals = compute_normals(surface) + pts = CellObjectConversion.to_points( + surface, name=f"{surface.name} {self.params.out_name}" + ) + pts.add_data( + { + "Nx": {"values": normals[:, 0], "association": "VERTEX"}, + "Ny": {"values": normals[:, 1], "association": "VERTEX"}, + "Nz": {"values": normals[:, 2], "association": "VERTEX"}, + } + ) + points.append(pts) + + merge_points = self.params.merge_points and len(points) > 1 + if merge_points: + points = [ + PointsMerger.merge_objects( + geoh5, + points, # type: ignore + add_data=True, + name=f"merged {self.params.out_name}", + ) + ] + + for pts in points: + pts.create_property_group( + properties=[pts.get_data(k)[0] for k in ["Nx", "Ny", "Nz"]], + name="Normals", + property_group_type=GroupTypeEnum.VECTOR, + ) + self.update_monitoring_directory(pts) + + +if __name__ == "__main__": + file = Path(sys.argv[1]).resolve() + driver = Driver.start(file) diff --git a/surface_apps/surface_normals/options.py b/surface_apps/surface_normals/options.py new file mode 100644 index 0000000..73f9985 --- /dev/null +++ b/surface_apps/surface_normals/options.py @@ -0,0 +1,38 @@ +# '''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''' +# Copyright (c) 2024-2025 Mira Geoscience Ltd. ' +# ' +# This file is part of surface-apps package. ' +# ' +# surface-apps is distributed under the terms and conditions of the MIT License +# (see LICENSE file at the root of this source code package). ' +# '''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''' + +from pathlib import Path +from typing import ClassVar + +from geoapps_utils.driver.data import BaseData +from geoh5py.objects import Surface +from pydantic import ConfigDict + +from surface_apps import assets_path + + +class SurfaceNormalsOptions(BaseData): + """ + Options for surface normals. + + :param surface: Triangulation on which normals will be computed + at triangle centers. + """ + + model_config = ConfigDict(arbitrary_types_allowed=True) + + name: ClassVar[str] = "surface_normals" + default_ui_json: ClassVar[Path] = assets_path() / "uijson/surface_normals.ui.json" + title: ClassVar[str] = "Surface Normals" + run_command: ClassVar[str] = "surface_apps.surface_normals.driver" + + conda_environment: str = "surface_apps" + surfaces: list[Surface] + merge_points: bool = True + out_name: str = "surface normals" diff --git a/tests/run_tests/iso_surfaces_test.py b/tests/run_tests/iso_surfaces_test.py index 073adf2..49351ca 100644 --- a/tests/run_tests/iso_surfaces_test.py +++ b/tests/run_tests/iso_surfaces_test.py @@ -15,7 +15,7 @@ from geoh5py.objects import BlockModel, Points, Surface from geoh5py.workspace import Workspace -from surface_apps.iso_surfaces.driver import IsoSurfacesDriver +from surface_apps.iso_surfaces.driver import Driver as IsoSurfacesDriver # pylint: disable=too-many-locals diff --git a/tests/run_tests/surface_normals_test.py b/tests/run_tests/surface_normals_test.py new file mode 100644 index 0000000..037c7be --- /dev/null +++ b/tests/run_tests/surface_normals_test.py @@ -0,0 +1,79 @@ +# '''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''' +# Copyright (c) 2025 Mira Geoscience Ltd. ' +# ' +# This file is part of surface-apps package. ' +# ' +# surface-apps is distributed under the terms and conditions of the MIT License +# (see LICENSE file at the root of this source code package). ' +# '''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''' + +import numpy as np +from geoh5py import Workspace +from geoh5py.objects import Surface + +from surface_apps.surface_normals.driver import Driver as SurfaceNormalsDriver +from surface_apps.surface_normals.options import SurfaceNormalsOptions + + +def create_surface(workspace: Workspace): + h = np.sqrt(2) + alpha = np.deg2rad(30) + vertices = np.array( + [ + [-h * np.cos(alpha), -h * np.sin(alpha), 0], + [h * np.sin(alpha), -h * np.cos(alpha), 0], + [h * np.cos(alpha), h * np.sin(alpha), 0], + [-h * np.sin(alpha), h * np.cos(alpha), 0], + [0, 0, -1], + [0, 0, 1], + ] + ) + cells = np.array( + [ + [1, 2, 5], + [0, 1, 5], + [3, 0, 5], + [2, 3, 5], + [1, 4, 2], + [0, 4, 1], + [3, 4, 0], + [2, 4, 3], + ] + ) + + surf = Surface.create(workspace, name="diamond", vertices=vertices, cells=cells) + return surf + + +def test_surface_normals(tmp_path): + with Workspace(tmp_path / "test.geoh5") as ws: + surf = create_surface(ws) + + opts = SurfaceNormalsOptions(geoh5=ws, surfaces=[surf]) + opts.write_ui_json(tmp_path / "test.ui.json") + normals = SurfaceNormalsDriver.start(tmp_path / "test.ui.json") + + with Workspace(tmp_path / "test.geoh5") as ws: + pts = ws.get_entity("diamond surface normals")[0] + pg = pts.fetch_property_group("Normals") + + normals = np.column_stack([ws.get_entity(k)[0].values for k in pg.properties]) + h = np.sqrt(2) + beta = np.deg2rad(15) + validation = ( + np.array( + [ + [h * np.cos(beta), -h * np.sin(beta), h], + [-h * np.sin(beta), -h * np.cos(beta), h], + [-h * np.cos(beta), h * np.sin(beta), h], + [h * np.sin(beta), h * np.cos(beta), h], + [h * np.cos(beta), -h * np.sin(beta), -h], + [-h * np.sin(beta), -h * np.cos(beta), -h], + [-h * np.cos(beta), h * np.sin(beta), -h], + [h * np.sin(beta), h * np.cos(beta), -h], + ] + ) + / 2 + ) + + assert np.allclose(normals, validation) diff --git a/tests/utils_test.py b/tests/utils_test.py index c418677..9c38103 100644 --- a/tests/utils_test.py +++ b/tests/utils_test.py @@ -7,6 +7,7 @@ # (see LICENSE file at the root of this source code package). ' # '''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''' + import numpy as np from geoh5py import Workspace from geoh5py.objects import Curve, Points