diff --git a/geos-trame/src/geos/trame/app/components/alertHandler.py b/geos-trame/src/geos/trame/app/components/alertHandler.py index 230b041..ea206d6 100644 --- a/geos-trame/src/geos/trame/app/components/alertHandler.py +++ b/geos-trame/src/geos/trame/app/components/alertHandler.py @@ -5,6 +5,19 @@ from trame.widgets import vuetify3 +from enum import Enum + + +class AlertType( str, Enum ): + """Enum representing the type of VAlert. + + For more information, see the uetify documentation: + https://vuetifyjs.com/en/api/VAlert/#props-type + """ + SUCCESS = 'success' + WARNING = 'warning' + ERROR = 'error' + class AlertHandler( vuetify3.VContainer ): """Vuetify component used to display an alert status. @@ -26,8 +39,9 @@ def __init__( self ) -> None: self.state.alerts = [] - self.ctrl.on_add_error.add_task( self.add_error ) - self.ctrl.on_add_warning.add_task( self.add_warning ) + self.server.controller.on_add_success.add_task( self.add_success ) + self.server.controller.on_add_warning.add_task( self.add_warning ) + self.server.controller.on_add_error.add_task( self.add_error ) self.generate_alert_ui() @@ -75,7 +89,7 @@ def add_alert( self, type: str, title: str, message: str ) -> None: self.state.dirty( "alerts" ) self.state.flush() - if type == "warning": + if type == AlertType.WARNING: asyncio.get_event_loop().call_later( self.__lifetime_of_alert, self.on_close, alert_id ) async def add_warning( self, title: str, message: str ) -> None: @@ -86,6 +100,10 @@ async def add_error( self, title: str, message: str ) -> None: """Add an alert of type 'error'.""" self.add_alert( "error", title, message ) + async def add_success( self, title: str, message: str ) -> None: + """Add an alert of type 'success'.""" + self.add_alert( AlertType.SUCCESS, title, message ) + def on_close( self, alert_id: int ) -> None: """Remove in the state the alert associated to the given id.""" self.state.alerts = list( filter( lambda i: i[ "id" ] != alert_id, self.state.alerts ) ) diff --git a/geos-trame/src/geos/trame/app/core.py b/geos-trame/src/geos/trame/app/core.py index 1f66b96..0a8f409 100644 --- a/geos-trame/src/geos/trame/app/core.py +++ b/geos-trame/src/geos/trame/app/core.py @@ -61,7 +61,7 @@ def __init__( self, server: Server, file_name: str ) -> None: self.ctrl.simput_reload_data = self.simput_widget.reload_data # Tree - self.tree = DeckTree( self.state.sm_id ) + self.tree = DeckTree( self.state.sm_id, self.ctrl ) # Viewers self.region_viewer = RegionViewer() diff --git a/geos-trame/src/geos/trame/app/data_types/renderable.py b/geos-trame/src/geos/trame/app/data_types/renderable.py index e031240..af26a62 100644 --- a/geos-trame/src/geos/trame/app/data_types/renderable.py +++ b/geos-trame/src/geos/trame/app/data_types/renderable.py @@ -6,6 +6,7 @@ class Renderable( Enum ): """Enum class for renderable types and their ids.""" + BOX = "Box" VTKMESH = "VTKMesh" INTERNALMESH = "InternalMesh" INTERNALWELL = "InternalWell" diff --git a/geos-trame/src/geos/trame/app/deck/tree.py b/geos-trame/src/geos/trame/app/deck/tree.py index 4979f3b..b732dde 100644 --- a/geos-trame/src/geos/trame/app/deck/tree.py +++ b/geos-trame/src/geos/trame/app/deck/tree.py @@ -4,26 +4,28 @@ import os from collections import defaultdict from typing import Any - import dpath import funcy from pydantic import BaseModel -from trame_simput import get_simput_manager + from xsdata.formats.dataclass.parsers.config import ParserConfig from xsdata.formats.dataclass.serializers.config import SerializerConfig from xsdata.utils import text from xsdata_pydantic.bindings import DictDecoder, XmlContext, XmlSerializer +from trame_server.controller import Controller +from trame_simput import get_simput_manager + from geos.trame.app.deck.file import DeckFile from geos.trame.app.geosTrameException import GeosTrameException -from geos.trame.app.utils.file_utils import normalize_path, format_xml from geos.trame.schema_generated.schema_mod import Problem, Included, File, Functions +from geos.trame.app.utils.file_utils import normalize_path, format_xml class DeckTree( object ): """A tree that represents a deck file along with all the available blocks and parameters.""" - def __init__( self, sm_id: str | None = None, **kwargs: Any ) -> None: + def __init__( self, sm_id: str | None = None, ctrl: Controller = None, **kwargs: Any ) -> None: """Constructor.""" super( DeckTree, self ).__init__( **kwargs ) @@ -33,6 +35,7 @@ def __init__( self, sm_id: str | None = None, **kwargs: Any ) -> None: self.root = None self.input_has_errors = False self._sm_id = sm_id + self._ctrl = ctrl def set_input_file( self, input_filename: str ) -> None: """Set a new input file. @@ -172,6 +175,8 @@ def write_files( self ) -> None: file.write( model_as_xml ) file.close() + self._ctrl.on_add_success( title="File saved", message=f"File {basename} has been saved." ) + @staticmethod def _append_include_file( model: Problem, included_file_path: str ) -> None: """Append an Included object which follows this structure according to the documentation. diff --git a/geos-trame/src/geos/trame/app/ui/viewer/boxViewer.py b/geos-trame/src/geos/trame/app/ui/viewer/boxViewer.py new file mode 100644 index 0000000..05b59f1 --- /dev/null +++ b/geos-trame/src/geos/trame/app/ui/viewer/boxViewer.py @@ -0,0 +1,111 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright 2023-2024 TotalEnergies. +# SPDX-FileContributor: Lucas Givord - Kitware +import pyvista as pv + +from geos.trame.schema_generated.schema_mod import Box + +import re + + +class BoxViewer: + """A BoxViewer represents a Box and its intersected cell in a mesh. + + This mesh is represented in GEOS with a Box. + """ + + def __init__( self, mesh: pv.UnstructuredGrid, box: Box ) -> None: + """Initialize the BoxViewer with a mesh and a box.""" + self._mesh: pv.UnstructuredGrid = mesh + + self._box: Box = box + self._box_polydata: pv.PolyData = None + self._box_polydata_actor: pv.Actor = None + + self._extracted_cell: pv.UnstructuredGrid = None + self._extracted_cell_actor: pv.Actor = None + + self._compute_box_as_polydata() + self._compute_intersected_cell() + + def append_to_plotter( self, plotter: pv.Plotter ) -> None: + """Append the box and the intersected cell to the plotter. + + The box is represented as a polydata with a low opacity. + """ + self._box_polydata_actor = plotter.add_mesh( self._box_polydata, opacity=0.2 ) + + if self._extracted_cell is not None: + self._extracted_cell_actor = plotter.add_mesh( self._extracted_cell, show_edges=True ) + + def reset( self, plotter: pv.Plotter ) -> None: + """Reset the box viewer by removing the box and the intersected cell from the plotter.""" + if self._box_polydata_actor is not None: + plotter.remove_actor( self._box_polydata_actor ) + + if self._extracted_cell_actor is not None: + plotter.remove_actor( self._extracted_cell_actor ) + + self._box_polydata = None + self._extracted_cell = None + + def _compute_box_as_polydata( self ) -> None: + """Create a polydata reresenting a BBox using pyvista and coordinates from the Geos Box.""" + bounding_box: list[ float ] = self._retrieve_bounding_box() + self._box_polydata = pv.Box( bounds=bounding_box ) + + def _retrieve_bounding_box( self ) -> list[ float ]: + """This method converts bounding box information from Box into a list of coordinates readable by pyvista. + + e.g., this Box: + + + + will return [1150, 1250, 700, 800, 62, 137]. + """ + # split str and remove brackets + min_point_str = re.findall( r"-?\d+\.\d+|-?\d+", self._box.x_min ) + max_point_str = re.findall( r"-?\d+\.\d+|-?\d+", self._box.x_max ) + + min_point = list( map( float, min_point_str ) ) + max_point = list( map( float, max_point_str ) ) + + return [ + min_point[ 0 ], + max_point[ 0 ], + min_point[ 1 ], + max_point[ 1 ], + min_point[ 2 ], + max_point[ 2 ], + ] + + def _compute_intersected_cell( self ) -> None: + """Extract the cells from the mesh that are inside the box.""" + ids = self._mesh.find_cells_within_bounds( self._box_polydata.bounds ) + + saved_ids: list[ int ] = [] + + for id in ids: + cell: pv.vtkCell = self._mesh.GetCell( id ) + + is_inside = self._check_cell_inside_box( cell, self._box_polydata.bounds ) + if is_inside: + saved_ids.append( id ) + + if len( saved_ids ) > 0: + self._extracted_cell = self._mesh.extract_cells( saved_ids ) + + def _check_cell_inside_box( self, cell: pv.Cell, box_bounds: list[ float ] ) -> bool: + """Check if the cell is inside the box bounds. + + A cell is considered inside the box if his bounds are completely + inside the box bounds. + """ + cell_bounds = cell.GetBounds() + is_inside_in_x = cell_bounds[ 0 ] >= box_bounds[ 0 ] and cell_bounds[ 1 ] <= box_bounds[ 1 ] + is_inside_in_y = cell_bounds[ 2 ] >= box_bounds[ 2 ] and cell_bounds[ 3 ] <= box_bounds[ 3 ] + is_inside_in_z = cell_bounds[ 4 ] >= box_bounds[ 4 ] and cell_bounds[ 5 ] <= box_bounds[ 5 ] + + return is_inside_in_x and is_inside_in_y and is_inside_in_z diff --git a/geos-trame/src/geos/trame/app/ui/viewer/viewer.py b/geos-trame/src/geos/trame/app/ui/viewer/viewer.py index 8064204..fea98c0 100644 --- a/geos-trame/src/geos/trame/app/ui/viewer/viewer.py +++ b/geos-trame/src/geos/trame/app/ui/viewer/viewer.py @@ -11,10 +11,11 @@ from vtkmodules.vtkRenderingCore import vtkActor from geos.trame.app.deck.tree import DeckTree +from geos.trame.app.ui.viewer.boxViewer import BoxViewer from geos.trame.app.ui.viewer.perforationViewer import PerforationViewer from geos.trame.app.ui.viewer.regionViewer import RegionViewer from geos.trame.app.ui.viewer.wellViewer import WellViewer -from geos.trame.schema_generated.schema_mod import Vtkmesh, Vtkwell, InternalWell, Perforation +from geos.trame.schema_generated.schema_mod import Box, Vtkmesh, Vtkwell, InternalWell, Perforation pv.OFF_SCREEN = True @@ -59,6 +60,7 @@ def __init__( self.SELECTED_DATA_ARRAY = "viewer_selected_data_array" self.state.change( self.SELECTED_DATA_ARRAY )( self._update_actor_array ) + self.box_engine: BoxViewer | None = None self.region_engine = region_viewer self.well_engine = well_viewer self._perforations: dict[ str, PerforationViewer ] = {} @@ -138,6 +140,24 @@ def update_viewer( self, active_block: BaseModel, path: str, show_obj: bool ) -> if isinstance( active_block, Perforation ): self._update_perforation( active_block, show_obj, path ) + if isinstance( active_block, Box ): + if self.region_engine.input.number_of_cells == 0 and show_obj: + self.ctrl.on_add_warning( + "Can't display " + active_block.name, + "Please display the mesh before creating a well", + ) + return + + if self.box_engine is not None: + self.box_engine.reset( self.plotter ) + + if not show_obj: + return + + box: Box = active_block + self.box_engine = BoxViewer( self.region_engine.input, box ) + self.box_engine.append_to_plotter( self.plotter ) + def _on_clip_visibility_change( self, **kwargs: Any ) -> None: """Toggle cut plane visibility for all actors. @@ -215,6 +235,7 @@ def _update_internalwell( self, path: str, show: bool ) -> None: """ if not show: self.plotter.remove_actor( self.well_engine.get_actor( path ) ) # type: ignore + self.well_engine.remove_actor( path ) return tube_actor = self.plotter.add_mesh( self.well_engine.get_tube( self.well_engine.get_last_mesh_idx() ) ) @@ -229,6 +250,7 @@ def _update_vtkwell( self, path: str, show: bool ) -> None: """ if not show: self.plotter.remove_actor( self.well_engine.get_actor( path ) ) # type: ignore + self.well_engine.remove_actor( path ) return tube_actor = self.plotter.add_mesh( self.well_engine.get_tube( self.well_engine.get_last_mesh_idx() ) ) diff --git a/geos-trame/src/geos/trame/app/ui/viewer/wellViewer.py b/geos-trame/src/geos/trame/app/ui/viewer/wellViewer.py index 4196f7b..237d50c 100644 --- a/geos-trame/src/geos/trame/app/ui/viewer/wellViewer.py +++ b/geos-trame/src/geos/trame/app/ui/viewer/wellViewer.py @@ -17,6 +17,12 @@ class Well: well_path: str polyline: pv.PolyData tube: pv.PolyData + + +@dataclass +class WellActor: + """A WellActor stores the VTK Actor representing a well.""" + well_path: str actor: pv.Actor @@ -28,6 +34,7 @@ def __init__( self, size: float, amplification: float ) -> None: A Well in GEOS could a InternalWell or a Vtkwell. """ self._wells: list[ Well ] = [] + self._wells_actors: list[ WellActor ] = [] self.size: float = size self.amplification: float = amplification @@ -51,7 +58,8 @@ def add_mesh( self, mesh: pv.PolyData, mesh_path: str ) -> int: radius = self.size * ( self.STARTING_VALUE / 100 ) tube = mesh.tube( radius=radius, n_sides=50 ) - self._wells.append( Well( mesh_path, mesh, tube, pv.Actor() ) ) + self._wells.append( Well( mesh_path, mesh, tube ) ) + self._wells_actors.append( WellActor( mesh_path, pv.Actor() ) ) return len( self._wells ) - 1 @@ -78,21 +86,21 @@ def get_tube_size( self ) -> float: def append_actor( self, perforation_path: str, tube_actor: pv.Actor ) -> None: """Append a given actor, typically the Actor returned by the pv.Plotter() when a given mes is added.""" - index = self._get_index_from_perforation( perforation_path ) + index = self._get_actor_index_from_perforation( perforation_path ) if index == -1: print( "Cannot found the well to remove from path: ", perforation_path ) return - self._wells[ index ].actor = tube_actor + self._wells_actors[ index ].actor = tube_actor def get_actor( self, perforation_path: str ) -> pv.Actor | None: """Retrieve the polyline linked to a given perforation path.""" - index = self._get_index_from_perforation( perforation_path ) + index = self._get_actor_index_from_perforation( perforation_path ) if index == -1: print( "Cannot found the well to remove from path: ", perforation_path ) return None - return self._wells[ index ].actor + return self._wells_actors[ index ].actor def update( self, value: float ) -> None: """Update the radius of the tubes.""" @@ -108,6 +116,14 @@ def remove( self, perforation_path: str ) -> None: self._wells.remove( self._wells[ index ] ) + def remove_actor( self, perforation_path: str ) -> None: + """Clear all data stored in this class.""" + index = self._get_actor_index_from_perforation( perforation_path ) + if index == -1: + print( "Cannot found the well to remove from path: ", perforation_path ) + + self._wells_actors.remove( self._wells_actors[ index ] ) + def _get_index_from_perforation( self, perforation_path: str ) -> int: """Retrieve the well associated to a given perforation, otherwise return -1.""" index = -1 @@ -121,6 +137,19 @@ def _get_index_from_perforation( self, perforation_path: str ) -> int: return index + def _get_actor_index_from_perforation( self, perforation_path: str ) -> int: + """Retrieve the well actor associated to a given perforation, otherwise return -1.""" + index = -1 + if len( self._wells_actors ) == 0: + return index + + for i in range( 0, len( self._wells_actors ) ): + if self._wells_actors[ i ].well_path in perforation_path: + index = i + break + + return index + def get_number_of_wells( self ) -> int: """Get the number of wells in the viewer.""" return len( self._wells ) diff --git a/geos-trame/tests/data/geosDeck/geosDeck.xml b/geos-trame/tests/data/geosDeck/geosDeck.xml index a4a7de8..efff1d4 100644 --- a/geos-trame/tests/data/geosDeck/geosDeck.xml +++ b/geos-trame/tests/data/geosDeck/geosDeck.xml @@ -127,8 +127,8 @@ + xMin="{ 3509, 4117, -596 }" + xMax="{ 4482, 5041, -500 }"/> diff --git a/geos-trame/tests/test_box_intersection.py b/geos-trame/tests/test_box_intersection.py new file mode 100644 index 0000000..22b39f0 --- /dev/null +++ b/geos-trame/tests/test_box_intersection.py @@ -0,0 +1,36 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright 2023-2024 TotalEnergies. +# SPDX-FileContributor: Kitware +# ruff: noqa +from pathlib import Path + +from trame_server import Server +from trame_server.state import State +from trame_vuetify.ui.vuetify3 import VAppLayout + +from geos.trame.app.core import GeosTrame +from tests.trame_fixtures import trame_state, trame_server_layout + + +def test_box_intersection( trame_server_layout: tuple[ Server, VAppLayout ] ) -> None: + """Test box intersection.""" + root_path = Path( __file__ ).parent.absolute().__str__() + file_name = root_path + "/data/geosDeck/geosDeck.xml" + + app = GeosTrame( trame_server_layout[ 0 ], file_name ) + app.state.ready() + + app.deckInspector.state.object_state = [ "Problem/Mesh/0/VTKMesh/0", True ] + app.deckInspector.state.flush() + + app.deckInspector.state.object_state = [ "Problem/Geometry/0/Box/0", True ] + app.deckInspector.state.flush() + + box = app.deckViewer.box_engine._box + cells = app.deckViewer.box_engine._extracted_cell + + assert box is not None + assert box.x_min == '{ 3509, 4117, -596 }' + assert box.x_max == '{ 4482, 5041, -500 }' + assert cells is not None + assert cells.number_of_cells == 1