From b3f92b2bf65c8ff35424140d7083045ec29f4608 Mon Sep 17 00:00:00 2001 From: Lucas Givord Date: Fri, 18 Apr 2025 11:51:16 +0200 Subject: [PATCH 1/7] feat(wells): add visualization and interaction with well Improve DeckViewer to be able to show mesh (Region), wells (InternalWell, Vtkmesh) and perforation (Perforation) Update also the gui to be able to hide and control radius property of these datas. --- geos-trame/src/geos_trame/app/core.py | 2 +- geos-trame/src/geos_trame/app/deck/file.py | 1 + geos-trame/src/geos_trame/app/ui/inspector.py | 30 +- geos-trame/src/geos_trame/app/ui/viewer.py | 299 ---------------- .../src/geos_trame/app/ui/viewer/__init__.py | 0 .../app/ui/viewer/perforationViewer.py | 35 ++ .../geos_trame/app/ui/viewer/regionViewer.py | 31 ++ .../src/geos_trame/app/ui/viewer/viewer.py | 321 ++++++++++++++++++ .../geos_trame/app/ui/viewer/wellViewer.py | 131 +++++++ 9 files changed, 534 insertions(+), 316 deletions(-) delete mode 100644 geos-trame/src/geos_trame/app/ui/viewer.py create mode 100644 geos-trame/src/geos_trame/app/ui/viewer/__init__.py create mode 100644 geos-trame/src/geos_trame/app/ui/viewer/perforationViewer.py create mode 100644 geos-trame/src/geos_trame/app/ui/viewer/regionViewer.py create mode 100644 geos-trame/src/geos_trame/app/ui/viewer/viewer.py create mode 100644 geos-trame/src/geos_trame/app/ui/viewer/wellViewer.py diff --git a/geos-trame/src/geos_trame/app/core.py b/geos-trame/src/geos_trame/app/core.py index 4b1b77d21..a1a5fcf82 100644 --- a/geos-trame/src/geos_trame/app/core.py +++ b/geos-trame/src/geos_trame/app/core.py @@ -13,7 +13,7 @@ from geos_trame.app.ui.inspector import DeckInspector from geos_trame.app.ui.plotting import DeckPlotting from geos_trame.app.ui.timeline import TimelineEditor -from geos_trame.app.ui.viewer import DeckViewer +from geos_trame.app.ui.viewer.viewer import DeckViewer import sys diff --git a/geos-trame/src/geos_trame/app/deck/file.py b/geos-trame/src/geos_trame/app/deck/file.py index dc1308e40..3840aa534 100644 --- a/geos-trame/src/geos_trame/app/deck/file.py +++ b/geos-trame/src/geos_trame/app/deck/file.py @@ -221,6 +221,7 @@ def _build_inspect_tree_inner( key, obj, path ) -> dict: "VTKMesh", "InternalMesh", "InternalWell", + "VTKWell", "Perforation", ] sub_node[ "drawn" ] = False diff --git a/geos-trame/src/geos_trame/app/ui/inspector.py b/geos-trame/src/geos_trame/app/ui/inspector.py index 4f4f348c2..a5413e1e4 100644 --- a/geos-trame/src/geos_trame/app/ui/inspector.py +++ b/geos-trame/src/geos_trame/app/ui/inspector.py @@ -15,6 +15,7 @@ class Renderable( Enum ): VTKMESH = "VTKMesh" INTERNALMESH = "InternalMesh" INTERNALWELL = "InternalWell" + VTKWELL = "VTKWell" PERFORATION = "Perforation" @@ -201,7 +202,7 @@ def __init__( self, listen_to_active=True, source=None, **kwargs ): self._source = None self.listen_to_active = listen_to_active - self.state.obj_path = "" + self.state.object_state = [ "", False ] # register used types from Problem self.simput_types = [] @@ -225,18 +226,18 @@ def on_change( topic, ids=None, **kwargs ): with self: with vuetify.Template( v_slot_append="{ item }" ): - with vuetify.VBtn( - v_if=( "item.is_drawable" ), - icon=True, - flat=True, - slim=True, - input_value=( "item.drawn" ), - click=( self.to_draw_change, "[item.id]" ), - ): - vuetify.VIcon( - "{{ ((item.drawn)) ? 'mdi-eye' : 'mdi-eye-off' }}", - v_if=( "item.is_drawable" ), - ) + vuetify.VCheckboxBtn( + v_if="item.is_drawable", + icon=True, + true_icon="mdi-eye", + false_icon="mdi-eye-off", + dense=True, + hide_details=True, + update_modelValue=( self.to_draw_change, "[ item.id, $event ] " ), + ) + + def to_draw_change( self, id, drawn ): + self.state.object_state = [ id, drawn ] @property def source( self ): @@ -298,9 +299,6 @@ def set_source( self, v ): debug.set_property( key, getattr( active_block, key ) ) debug.commit() - def to_draw_change( self, path ): - self.state.obj_path = path - # def on_active_change(self, **_): # if self.listen_to_active: # print("on_active_change") diff --git a/geos-trame/src/geos_trame/app/ui/viewer.py b/geos-trame/src/geos_trame/app/ui/viewer.py deleted file mode 100644 index 29a5debfd..000000000 --- a/geos-trame/src/geos_trame/app/ui/viewer.py +++ /dev/null @@ -1,299 +0,0 @@ -# SPDX-License-Identifier: Apache-2.0 -# SPDX-FileCopyrightText: Copyright 2023-2024 TotalEnergies. -# SPDX-FileContributor: Lionel Untereiner -import pyvista as pv -from pyvista.trame.ui import plotter_ui -from trame.widgets import html, vtk -from trame.widgets import vuetify3 as vuetify - -from geos_trame.schema_generated.schema_mod import InternalWell, Vtkmesh - -# def setup_viewer(): -# pl = pv.Plotter() - -# p.reset_camera() - -# return p.ren_vin - -# prevents launching an external window with rendering -pv.OFF_SCREEN = True - - -class RegionViewer: - - def __init__( self ) -> None: - self.input: pv.UnstructuredGrid = pv.UnstructuredGrid() - self.mesh: pv.UnstructuredGrid - - def __call__( self, normal: tuple[ float ], origin: tuple[ float ] ) -> None: - self.update_clip( normal, origin ) - - def add_mesh( self, mesh: pv.UnstructuredGrid ) -> None: - self.input = mesh # type: ignore - self.mesh = self.input.copy() # type: ignore - - def update_clip( self, normal: tuple[ float ], origin: tuple[ float ] ) -> None: - self.mesh.copy_from( self.input.clip( normal=normal, origin=origin, crinkle=True ) ) # type: ignore - - -# class WellViewer: -# def __init__(self, size: float, amplification: float) -> None: -# self.input: list[pv.PolyData] = [] -# self.tubes: list[pv.PolyData] = [] -# self.size: float = size -# self.amplification: float = amplification -# self.STARTING_VALUE: float = 5.0 - -# def __call__(self, value: float) -> None: -# self.update(value) - -# def add_mesh(self, mesh: pv.PolyData) -> None: -# self.input.append(mesh) # type: ignore -# radius = self.size * (self.STARTING_VALUE / 100) -# self.tubes.append( -# mesh.tube( -# radius=radius, n_sides=50 -# ) # .scale([1.0, 1.0, self.amplification], inplace=True) -# ) # type: ignore - -# def update(self, value: float) -> None: -# radius = self.size * (value / 100) -# for idx, m in enumerate(self.input): -# self.tubes[idx].copy_from( -# m.tube( -# radius=radius, n_sides=50 -# ) # .scale([1.0, 1.0, self.amplification], inplace=True) -# ) - -# class GeosPlotterBase: -# def add_region(self, mesh: pv.UnstructuredGrid, name: str, zscale: int = 1) -> None: -# super().add_mesh(mesh, name=name, **kwargs) - -# def add_well(self, mesh: pv.PolyData, name: str, zscale: int = 1) -> None: -# # build tube -# super().add_mesh() - -# def update_clip(self, normal: tuple[float], origin: tuple[float]) -> None: -# pass -# # for m in super().meshes: -# # m. - -# class GeosPlotter(GeosPlotterBase, pv.Plotter): -# pass - -### VIEWER - - -def button( click, icon, tooltip ): # numpydoc ignore=PR01 - """Create a vuetify button.""" - with vuetify.VTooltip( bottom=True ): - with vuetify.Template( v_slot_activator="{ on, attrs }" ): - with vuetify.VBtn( icon=True, v_bind="attrs", v_on="on", click=click ): - vuetify.VIcon( icon ) - html.Span( tooltip ) - - -def checkbox( model, icons, tooltip ): # numpydoc ignore=PR01 - """Create a vuetify checkbox.""" - with vuetify.VTooltip( bottom=True ): - with vuetify.Template( v_slot_activator="{ on, attrs }" ): - with html.Div( v_on="on", v_bind="attrs" ): - vuetify.VCheckbox( - v_model=model, - on_icon=icons[ 0 ], - off_icon=icons[ 1 ], - dense=True, - hide_details=True, - classes="my-0 py-0 ml-1", - ) - html.Span( tooltip ) - - -def spin_edit( model, tooltip ): # numpydoc ignore=PR01 - """Create a vuetify slider.""" - with vuetify.VTooltip( bottom=True ): - with vuetify.Template( v_slot_activator="{ on, attrs }" ): - with html.Div( v_on="on", v_bind="attrs" ): - vuetify.VTextField( - v_model=model, - dense=True, - hide_details=True, - classes="my-0 py-0 ml-1", - type="number", - counter="3", - # counter_value= - ) - html.Span( tooltip ) - - -class DeckViewer( vuetify.VCard ): - - def __init__( self, source, **kwargs ): - super().__init__( **kwargs ) - - self._source = source - self._pl = pv.Plotter() - - self.CUT_PLANE = f"_cut_plane_visibility" - self.ZAMPLIFICATION = f"_z_amplification" - self.server.state[ self.CUT_PLANE ] = False - self.server.state[ self.ZAMPLIFICATION ] = 1 - - self.state.change( "obj_path" )( self.add_to_3dviewer ) - - self.region_engine = RegionViewer() - - # self.well_engine = WellViewer() - - with self: - vuetify.VCardTitle( "3D View" ) - view = plotter_ui( - self._pl, - add_menu_items=self.rendering_menu_extra_items, - style="position: absolute;", - ) - self.ctrl.view_update = view.update - - @property - def plotter( self ): - return self._pl - - @property - def source( self ): - return self._source - - def rendering_menu_extra_items( self ): - self.state.change( self.CUT_PLANE )( self.on_cut_plane_visiblity_change ) - vuetify.VDivider( vertical=True, classes="mr-3" ) - # html.Span('foo', classes="mr-3") - # spin_edit( - # model=(self.ZAMPLIFICATION, 3), - # tooltip=f"Z Amplification", - # ) - - # tooltip=f"Toggle cut plane visibility ({{{{ {self.CUT_PLANE} ? 'on' : 'off' }}}})", - # with vuetify.VBtn( - # icon=True, - # # v_on="on", - # # v_bind="attrs", - # # click="(event) => {event.stopPropagation(); event.preventDefault();}", - # ): - # vuetify.VIcon( - # "mdi-plus-circle", - # ) - - def on_cut_plane_visiblity_change( self, **kwargs ): - pass - """Toggle cut plane visibility for all actors. - - Parameters - ---------- - **kwargs : dict, optional - Unused keyword arguments. - - """ - # value = kwargs[self.CUT_PLANE] - # for renderer in self.plotter.renderers: - # for actor in renderer.actors.values(): - # if isinstance(actor, pyvista.Actor): - # actor.prop.show_edges = value - # self.update() - - def add_to_3dviewer( self, obj_path, **kwargs ): - path = obj_path - - if path == "": - return - - active_block = self.source.decode( path ) - - # def update_tree(entry, search_id): - - # print("entry :", entry) - # print("search_id : ", search_id) - - # print(entry["id"]) - # if entry["id"] == search_id: - # entry["drawn"] = True - # return - - # for child in entry["children"]: - # update_tree(child, search_id) - # for child in entry["hidden_children"]: - # update_tree(child, search_id) - - # print(self.server.state.deck_tree[0]) - # update_tree(self.server.state.deck_tree, path) - # self.server.state.dirty("deck_tree") - - if isinstance( active_block, Vtkmesh ): - self.region_engine.add_mesh( pv.read( self.source.get_abs_path( active_block.file ) ) ) - self.plotter.add_mesh_clip_plane( - self.region_engine.mesh, - origin=self.region_engine.mesh.center, - normal=[ -1, 0, 0 ], - crinkle=True, - show_edges=False, - cmap="glasbey_bw", - # cmap=cmap, - # clim=clim, - # categories=True, - scalars="attribute", - # n_colors=n, - ) - - self.server.controller.view_update() - - if isinstance( active_block, InternalWell ): - s = active_block.polyline_node_coords - points = np.array( literal_eval( s.translate( tr ) ), dtype=np.float64 ) - tip = points[ 0 ] - - s = active_block.polyline_segment_conn - lines = np.array( literal_eval( s.translate( tr ) ), dtype=np.int64 ) - v_indices = np.unique( lines.flatten() ) - - r = literal_eval( active_block.radius.translate( tr ) ) - radius = np.repeat( r, points.shape[ 0 ] ) - - vpoints = vtk.vtkPoints() - vpoints.SetNumberOfPoints( points.shape[ 0 ] ) - vpoints.SetData( numpy_to_vtk( points ) ) - - polyLine = vtk.vtkPolyLine() - polyLine.GetPointIds().SetNumberOfIds( len( v_indices ) ) - - for iline, vidx in enumerate( v_indices ): - polyLine.GetPointIds().SetId( iline, vidx ) - - cells = vtk.vtkCellArray() - cells.InsertNextCell( polyLine ) - - vradius = vtk.vtkDoubleArray() - vradius.SetName( "radius" ) - vradius.SetNumberOfComponents( 1 ) - vradius.SetNumberOfTuples( points.shape[ 0 ] ) - vradius.SetVoidArray( numpy_to_vtk( radius ), points.shape[ 0 ], 1 ) - - polyData = vtk.vtkPolyData() - polyData.SetPoints( vpoints ) - polyData.SetLines( cells ) - polyData.GetPointData().AddArray( vradius ) - polyData.GetPointData().SetActiveScalars( "radius" ) - - bounds = self.region_engine.mesh.bounds - - xsize = bounds[ 1 ] - bounds[ 0 ] - ysize = bounds[ 3 ] - bounds[ 2 ] - - maxsize = max( xsize, ysize ) - self.well_engine = WellViewer( maxsize, 1 ) - - self.well_engine.add_mesh( pv.wrap( polyData ) ) - - for m in self.well_engine.tubes: - actor = self.plotter.add_mesh( m, color=True, show_edges=False ) - - self.server.controller.view_update() - - # if isinstance(active_block, Perforation): diff --git a/geos-trame/src/geos_trame/app/ui/viewer/__init__.py b/geos-trame/src/geos_trame/app/ui/viewer/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/geos-trame/src/geos_trame/app/ui/viewer/perforationViewer.py b/geos-trame/src/geos_trame/app/ui/viewer/perforationViewer.py new file mode 100644 index 000000000..a08139193 --- /dev/null +++ b/geos-trame/src/geos_trame/app/ui/viewer/perforationViewer.py @@ -0,0 +1,35 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright 2023-2024 TotalEnergies. +# SPDX-FileContributor: Lucas Givord - Kitware +import pyvista as pv + + +class PerforationViewer: + """ + Class representing how storing a GEOS Perforation. + + A perforation is represented by 2 meshes: + _perforation_mesh : which is a sphere located where the perforation is + _extracted_cell : the extracted cell at the perforation location + """ + + def __init__( self, mesh: pv.PolyData, center: list[ float ], radius: float, actor: pv.Actor ) -> None: + self.perforation_mesh: pv.PolyData = mesh + self.center: list[ float ] = center + self.radius: float = radius + self.perforation_actor: pv.Actor = actor + self.extracted_cell: pv.Actor + + def add_extracted_cell( self, cell_actor: pv.Actor ) -> None: + self.extracted_cell = cell_actor + + def update_perforation_radius( self, value: float ) -> None: + self.radius = value + self.perforation_mesh = pv.Sphere( radius=self.radius, center=self.center ) + self.perforation_actor.GetMapper().SetInputDataObject( self.perforation_mesh ) + self.perforation_actor.GetMapper().Update() + + def reset( self ) -> None: + self.perforation_actor = pv.Actor() + self.perforation_mesh = pv.PolyData() + self.extracted_cell = pv.Actor() diff --git a/geos-trame/src/geos_trame/app/ui/viewer/regionViewer.py b/geos-trame/src/geos_trame/app/ui/viewer/regionViewer.py new file mode 100644 index 000000000..0ab47c7fd --- /dev/null +++ b/geos-trame/src/geos_trame/app/ui/viewer/regionViewer.py @@ -0,0 +1,31 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright 2023-2024 TotalEnergies. +# SPDX-FileContributor: Lucas Givord - Kitware +import pyvista as pv + + +class RegionViewer: + """ + Stores all related data information to represent the whole mesh. + + This mesh is represented in GEOS with a Region. + """ + + def __init__( self ) -> None: + self.input: pv.UnstructuredGrid + self.clip: pv.UnstructuredGrid + self.reset() + + def __call__( self, normal: tuple[ float ], origin: tuple[ float ] ) -> None: + self.update_clip( normal, origin ) + + def add_mesh( self, mesh: pv.UnstructuredGrid ) -> None: + self.input = mesh # type: ignore + self.clip = self.input.copy() # type: ignore + + def update_clip( self, normal: tuple[ float ], origin: tuple[ float ] ) -> None: + self.clip.copy_from( self.input.clip( normal=normal, origin=origin, crinkle=True ) ) # type: ignore + + def reset( self ) -> None: + self.input = pv.UnstructuredGrid() + self.clip = self.input diff --git a/geos-trame/src/geos_trame/app/ui/viewer/viewer.py b/geos-trame/src/geos_trame/app/ui/viewer/viewer.py new file mode 100644 index 000000000..d75f31a3c --- /dev/null +++ b/geos-trame/src/geos_trame/app/ui/viewer/viewer.py @@ -0,0 +1,321 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright 2023-2024 TotalEnergies. +# SPDX-FileContributor: Lucas Givord - Kitware +import pyvista as pv +from pyvista.trame.ui import plotter_ui +from trame.widgets import vuetify3 as vuetify +from trame.widgets import html + +from geos_trame.schema_generated.schema_mod import ( + Vtkmesh, + Vtkwell, + Perforation, + InternalWell, +) + +import geos_trame.app.ui.viewer.regionViewer as RegionViewer +import geos_trame.app.ui.viewer.wellViewer as WellViewer +import geos_trame.app.ui.viewer.perforationViewer as PerforationViewer +import numpy as np + +pv.OFF_SCREEN = True + + +class DeckViewer( vuetify.VCard ): + """ + Deck representing the 3D View using PyVista. + + This view can show: + - Vtkmesh, + - Vtkwell, + - Perforation, + - InternalWell + + Everything is handle in the method 'update_viewer()' which is trigger when the + 'state.object_state' changed (see DeckTree). + + This View handle widgets, such as clip widget or slider to control Wells or + Perforation settings. + """ + + def __init__( self, source, **kwargs ): + super().__init__( **kwargs ) + + self._source = source + self._pl = pv.Plotter() + + self.CUT_PLANE = "on_cut_plane_visibility_change" + self.ZAMPLIFICATION = "_z_amplification" + self.server.state[ self.CUT_PLANE ] = True + self.server.state[ self.ZAMPLIFICATION ] = 1 + + self.region_engine = RegionViewer.RegionViewer() + self.well_engine = WellViewer.WellViewer( 5, 5 ) + self._perforations: dict[ str, PerforationViewer.PerforationViewer ] = dict() + + self.state.change( "object_state" )( self.update_viewer ) + + with self: + vuetify.VCardTitle( "3D View" ) + view = plotter_ui( + self._pl, + add_menu_items=self.rendering_menu_extra_items, + style="position: absolute;", + ) + self.ctrl.view_update = view.update + + @property + def plotter( self ): + return self._pl + + @property + def source( self ): + return self._source + + def rendering_menu_extra_items( self ): + """ + Extend the default pyvista menu with custom button. + + For now, adding a button to show/hide all widgets. + """ + self.state.change( self.CUT_PLANE )( self._on_clip_visibility_change ) + vuetify.VDivider( vertical=True, classes="mr-1" ) + with vuetify.VTooltip( location="bottom" ): + with vuetify.Template( v_slot_activator=( "{ props }", ) ): + with html.Div( v_bind=( "props", ) ): + vuetify.VCheckbox( + v_model=( self.CUT_PLANE, True ), + icon=True, + true_icon="mdi-eye", + false_icon="mdi-eye-off", + dense=True, + hide_details=True, + ) + html.Span( "Show/Hide widgets" ) + + def update_viewer( self, object_state: list[ str, bool ], **kwargs ) -> None: + """ + Add from path the dataset given by the user. + Supported data type is: Vtkwell, Vtkmesh, InternalWell, Perforation. + + object_state : array used to store path to the data and if we want to show it or not. + """ + path = object_state[ 0 ] + show_obj = object_state[ 1 ] + + if path == "": + return + active_block = self.source.decode( path ) + + if isinstance( active_block, Vtkmesh ): + self._update_vtkmesh( active_block, show_obj ) + + if isinstance( active_block, Vtkwell ): + self._update_vtkwell( active_block, path, show_obj ) + + if isinstance( active_block, InternalWell ): + self._update_internalwell( active_block, path, show_obj ) + + if isinstance( active_block, Perforation ): + self._update_perforation( active_block, show_obj, path ) + + def _on_clip_visibility_change( self, **kwargs ): + """Toggle cut plane visibility for all actors. + + Parameters + ---------- + **kwargs : dict, optional + Unused keyword arguments. + + """ + show_widgets = kwargs[ self.CUT_PLANE ] + if show_widgets: + self._setup_slider() + else: + self._remove_slider() + + if self.plotter.plane_widgets: + widgets = self.plotter.plane_widgets + widgets[ 0 ].SetEnabled( show_widgets ) + self.plotter.render() + + def _setup_slider( self ) -> None: + """ + Create slider to control in the gui well parameters. + """ + + self.plotter.add_slider_widget( + self._on_change_tube_size, + [ 1, 100 ], + title="Wells radius", + pointa=( 0.02, 0.12 ), + pointb=( 0.30, 0.12 ), + title_opacity=0.5, + title_color="black", + title_height=0.02, + value=5, + ) + + self.plotter.add_slider_widget( + self._on_change_perforation_size, + [ 1, 100 ], + title="Perforation radius", + title_opacity=0.5, + pointa=( 0.02, 0.25 ), + pointb=( 0.30, 0.25 ), + title_color="black", + title_height=0.02, + value=5, + ) + + def _remove_slider( self ) -> None: + """ + Create slider to control in the gui well parameters. + """ + self.plotter.clear_slider_widgets() + + def _on_change_tube_size( self, value ) -> None: + self.well_engine.update( value ) + + def _on_change_perforation_size( self, value ) -> None: + for key, perforation in self._perforations.items(): + perforation.update_perforation_radius( value ) + + def _update_internalwell( self, well: InternalWell, path: str, show: bool ) -> None: + """ + Used to control the visibility of the InternalWell. + This method will create the mesh if it doesn't exist. + """ + if not show: + self.plotter.remove_actor( self.well_engine.get_actor( path ) ) + self.well_engine.remove( path ) + return + + points = self.__parse_polyline_node_coords( well.polyline_node_coords ) + + well_polydata: pv.PolyData + well_polydata = pv.points.lines_from_points( points ) + index = self.well_engine.add_mesh( well_polydata, path ) + + tube_actor = self.plotter.add_mesh( self.well_engine.get_tube( index ) ) + self.well_engine.append_actor( path, tube_actor ) + + self.server.controller.view_update() + + def _update_vtkwell( self, well: Vtkwell, path: str, show: bool ) -> None: + """ + Used to control the visibility of the Vtkwell. + This method will create the mesh if it doesn't exist. + """ + if not show: + self.plotter.remove_actor( self.well_engine.get_actor( path ) ) + self.well_engine.remove( path ) + return + + well_polydata = pv.PolyData.SafeDownCast( pv.read( self.source.get_abs_path( well.file ) ) ) + index = self.well_engine.add_mesh( well_polydata, path ) + + tube_actor = self.plotter.add_mesh( self.well_engine.get_tube( index ) ) + self.well_engine.append_actor( path, tube_actor ) + + self.server.controller.view_update() + + def _update_vtkmesh( self, mesh: Vtkmesh, show: bool ) -> None: + """ + Used to control the visibility of the Vtkmesh. + This method will create the mesh if it doesn't exist. + + Additionally, a clip filter will be added. + """ + + if not show: + self.region_engine.reset() + self.plotter.clear_plane_widgets() + self.plotter.remove_actor( self._clip_mesh ) + return + + unsctructured_grid = pv.UnstructuredGrid.SafeDownCast( pv.read( self.source.get_abs_path( mesh.file ) ) ) + self.region_engine.add_mesh( unsctructured_grid ) + active_scalar = self.region_engine.input.active_scalars_name + self._clip_mesh = self.plotter.add_mesh_clip_plane( + self.region_engine.input, + origin=self.region_engine.input.center, + normal=[ -1, 0, 0 ], + crinkle=True, + show_edges=False, + cmap="glasbey_bw", + scalars=active_scalar, + ) + + self.server.controller.view_update() + + def _update_perforation( self, perforation: Perforation, show: bool, path: str ) -> None: + """ + Generate VTK dataset from a perforation. + """ + + if not show: + if path in self._perforations: + self._remove_perforation( path ) + return + + distance_from_head = float( perforation.distance_from_head ) + self._add_perforation( distance_from_head, path ) + + def _remove_perforation( self, path: str ) -> None: + """ + Remove all actor related to the given path and clean the stored perforation + """ + saved_perforation: PerforationViewer.PerforationViewer = self._perforations[ path ] + self.plotter.remove_actor( saved_perforation.extracted_cell ) + self.plotter.remove_actor( saved_perforation.perforation_actor ) + saved_perforation.reset() + + def _add_perforation( self, distance_from_head: float, path: str ) -> None: + """ + Generate perforation dataset based on the distance from the top of a polyline + """ + + polyline: pv.PolyData = self.well_engine.get_mesh( path ) + if polyline is None: + return + + point = polyline.points[ 0 ] + point_offsetted = [ + point[ 0 ], + point[ 1 ], + point[ 2 ] - distance_from_head, + ] + + center = [ point[ 0 ], point[ 1 ], point[ 2 ] - float( distance_from_head ) ] + sphere = pv.Sphere( radius=5, center=center ) + + perforation_actor = self.plotter.add_mesh( sphere ) + saved_perforation = PerforationViewer.PerforationViewer( sphere, center, 5, perforation_actor ) + + id = self.region_engine.input.find_closest_cell( point_offsetted ) + cell = self.region_engine.input.extract_cells( [ id ] ) + cell_actor = self.plotter.add_mesh( cell ) + saved_perforation.add_extracted_cell( cell_actor ) + + self._perforations[ path ] = saved_perforation + + def __parse_polyline_node_coords( self, polyline_node_coords: str ): + """ + Internal method used to parse and convert a polyline_node_coords from an InternalWell. + This string always follow this for : "{ { 800, 1450, 395.646 }, { 800, 1450, -554.354 } }" + """ + nodes_str = polyline_node_coords.split( "}, {" ) + nodes_str[ 0 ] = nodes_str[ 0 ].replace( " ", "" ) + nodes_str[ 0 ] = nodes_str[ 0 ].replace( "{", "" ) + nodes_str[ 1 ] = nodes_str[ 1 ].replace( " ", "" ) + nodes_str[ 1 ] = nodes_str[ 1 ].replace( "}", "" ) + + top_point = np.array( nodes_str[ 0 ].split( "," ), dtype=float ) + bottom_point = np.array( nodes_str[ 1 ].split( "," ), dtype=float ) + + points = [] + points.append( top_point ) + points.append( bottom_point ) + + return points diff --git a/geos-trame/src/geos_trame/app/ui/viewer/wellViewer.py b/geos-trame/src/geos_trame/app/ui/viewer/wellViewer.py new file mode 100644 index 000000000..1fc13447e --- /dev/null +++ b/geos-trame/src/geos_trame/app/ui/viewer/wellViewer.py @@ -0,0 +1,131 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright 2023-2024 TotalEnergies. +# SPDX-FileContributor: Lucas Givord - Kitware +import pyvista as pv + +from dataclasses import dataclass + + +@dataclass +class Well: + """ + A Well is represented by a polyline and a tube. + + This class stores also the related actor and his given path + to simplify data management. + """ + + well_path: str + polyline: pv.PolyData + tube: pv.PolyData + actor: pv.Actor + + +class WellViewer: + """ + WellViewer stores all Well used in the pv.Plotter(). + + A Well in GEOS could a InternalWell or a Vtkwell. + """ + + def __init__( self, size: float, amplification: float ) -> None: + self._wells: list[ Well ] = [] + + self.size: float = size + self.amplification: float = amplification + self.STARTING_VALUE: float = 5.0 + + def __call__( self, value: float ) -> None: + self.update( value ) + + def add_mesh( self, mesh: pv.PolyData, mesh_path: str ) -> int: + """ + Store a given mesh representing a polyline. + This polyline will be used then to create a tube to represent this line. + + return the indexed position of the stored well. + """ + 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() ) ) + + return len( self._wells ) - 1 + + def get_mesh( self, perforation_path: str ) -> pv.PolyData | None: + """ + Retrieve the polyline linked to a given perforation path. + """ + index = self._get_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 ].polyline + + def get_tube( self, index: int ) -> pv.PolyData | None: + """ + Retrieve the polyline linked to a given perforation path. + """ + if index < 0 or index > len( self._wells ): + print( "Cannot get the tube at index: ", index ) + return None + + return self._wells[ index ].tube + + 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 ) + if index == -1: + print( "Cannot found the well to remove from path: ", perforation_path ) + return None + + self._wells[ 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 ) + if index == -1: + print( "Cannot found the well to remove from path: ", perforation_path ) + return None + + return self._wells[ index ].actor + + def update( self, value: float ) -> None: + radius = self.size * ( value / 100 ) + for idx, m in enumerate( self._wells ): + self._wells[ idx ].tube.copy_from( m.polyline.tube( radius=radius, n_sides=50 ) ) + + def remove( self, perforation_path: str ) -> None: + """ + Clear all data stored in this class. + """ + index = self._get_index_from_perforation( perforation_path ) + if index == -1: + print( "Cannot found the well to remove from path: ", perforation_path ) + + self._wells.remove( self._wells[ index ] ) + + def _get_index_from_perforation( self, perforation_path: str ) -> int: + """ + Retrieve the well associated to a given perforation, otherwise return -1. + """ + index = -1 + if len( self._wells ) == 0: + return index + + for i in range( 0, len( self._wells ) ): + if self._wells[ i ].well_path in perforation_path: + index = i + break + + return index + + def get_number_of_wells( self ): + return len( self._wells ) From 62ecce455dee3215903b44908e151d4cf375d630 Mon Sep 17 00:00:00 2001 From: Lucas Givord Date: Tue, 22 Apr 2025 15:12:52 +0200 Subject: [PATCH 2/7] fix(inspector): fix checking of if an item is renderable or not --- geos-trame/src/geos_trame/app/ui/inspector.py | 2 +- .../app/ui/viewer/perforationViewer.py | 3 +++ .../src/geos_trame/app/ui/viewer/viewer.py | 20 +++++++++++++++---- .../geos_trame/app/ui/viewer/wellViewer.py | 10 ++++++++-- 4 files changed, 28 insertions(+), 7 deletions(-) diff --git a/geos-trame/src/geos_trame/app/ui/inspector.py b/geos-trame/src/geos_trame/app/ui/inspector.py index a5413e1e4..2d8712798 100644 --- a/geos-trame/src/geos_trame/app/ui/inspector.py +++ b/geos-trame/src/geos_trame/app/ui/inspector.py @@ -101,7 +101,7 @@ def get_node_dict( obj, node_id, path ): title=node_name, children=children if len( children ) else [], hidden_children=[], - is_drawable=node_id in ( k for k in Renderable ), + is_drawable=node_id in ( k.value for k in Renderable ), drawn=False, ) diff --git a/geos-trame/src/geos_trame/app/ui/viewer/perforationViewer.py b/geos-trame/src/geos_trame/app/ui/viewer/perforationViewer.py index a08139193..28678c5e3 100644 --- a/geos-trame/src/geos_trame/app/ui/viewer/perforationViewer.py +++ b/geos-trame/src/geos_trame/app/ui/viewer/perforationViewer.py @@ -29,6 +29,9 @@ def update_perforation_radius( self, value: float ) -> None: self.perforation_actor.GetMapper().SetInputDataObject( self.perforation_mesh ) self.perforation_actor.GetMapper().Update() + def get_perforation_size( self ) -> float: + return self.radius + def reset( self ) -> None: self.perforation_actor = pv.Actor() self.perforation_mesh = pv.PolyData() 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 d75f31a3c..2712f93e9 100644 --- a/geos-trame/src/geos_trame/app/ui/viewer/viewer.py +++ b/geos-trame/src/geos_trame/app/ui/viewer/viewer.py @@ -144,28 +144,30 @@ def _setup_slider( self ) -> None: Create slider to control in the gui well parameters. """ + wells_radius = self._get_tube_size() self.plotter.add_slider_widget( self._on_change_tube_size, - [ 1, 100 ], + [ 1, 20 ], title="Wells radius", pointa=( 0.02, 0.12 ), pointb=( 0.30, 0.12 ), title_opacity=0.5, title_color="black", title_height=0.02, - value=5, + value=wells_radius, ) + perforation_radius = self._get_perforation_size() self.plotter.add_slider_widget( self._on_change_perforation_size, - [ 1, 100 ], + [ 1, 50 ], title="Perforation radius", title_opacity=0.5, pointa=( 0.02, 0.25 ), pointb=( 0.30, 0.25 ), title_color="black", title_height=0.02, - value=5, + value=perforation_radius, ) def _remove_slider( self ) -> None: @@ -177,10 +179,20 @@ def _remove_slider( self ) -> None: def _on_change_tube_size( self, value ) -> None: self.well_engine.update( value ) + def _get_tube_size( self ) -> float: + return self.well_engine.get_tube_size() + def _on_change_perforation_size( self, value ) -> None: for key, perforation in self._perforations.items(): perforation.update_perforation_radius( value ) + def _get_perforation_size( self ) -> float: + if len( self._perforations ) <= 0: + return 5 + + for key, perforation in self._perforations.items(): + return perforation.get_perforation_size() + def _update_internalwell( self, well: InternalWell, path: str, show: bool ) -> None: """ Used to control the visibility of the InternalWell. 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 1fc13447e..ece7d5482 100644 --- a/geos-trame/src/geos_trame/app/ui/viewer/wellViewer.py +++ b/geos-trame/src/geos_trame/app/ui/viewer/wellViewer.py @@ -73,6 +73,12 @@ def get_tube( self, index: int ) -> pv.PolyData | None: return self._wells[ index ].tube + def get_tube_size( self ) -> float: + """ + get the size used for the tube. + """ + return self.size + def append_actor( self, perforation_path: str, tube_actor: pv.Actor ) -> None: """ Append a given actor, typically the Actor returned by @@ -98,9 +104,9 @@ def get_actor( self, perforation_path: str ) -> pv.Actor | None: return self._wells[ index ].actor def update( self, value: float ) -> None: - radius = self.size * ( value / 100 ) + self.size = value for idx, m in enumerate( self._wells ): - self._wells[ idx ].tube.copy_from( m.polyline.tube( radius=radius, n_sides=50 ) ) + self._wells[ idx ].tube.copy_from( m.polyline.tube( radius=self.size, n_sides=50 ) ) def remove( self, perforation_path: str ) -> None: """ From f7b2e0bb1d329afcc76ec21acd3dee1a6a6041e3 Mon Sep 17 00:00:00 2001 From: Lucas Givord Date: Fri, 23 May 2025 08:47:43 +0200 Subject: [PATCH 3/7] fix(polyline): make sure to generate a polyline based on connectivity Previously we didn't take in account the connectivty. The geosDeck used in the test has been updated to check this fix. --- .../src/geos_trame/app/ui/viewer/viewer.py | 46 ++- geos-trame/tests/data/geosDeck/geosDeck.xml | 362 ++++++++++++++++++ 2 files changed, 391 insertions(+), 17 deletions(-) create mode 100644 geos-trame/tests/data/geosDeck/geosDeck.xml 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 2712f93e9..bdf9fb1a1 100644 --- a/geos-trame/src/geos_trame/app/ui/viewer/viewer.py +++ b/geos-trame/src/geos_trame/app/ui/viewer/viewer.py @@ -16,7 +16,10 @@ import geos_trame.app.ui.viewer.regionViewer as RegionViewer import geos_trame.app.ui.viewer.wellViewer as WellViewer import geos_trame.app.ui.viewer.perforationViewer as PerforationViewer +from geos_trame.app.geosTrameException import GeosTrameException + import numpy as np +from typing import Type, Any pv.OFF_SCREEN = True @@ -203,10 +206,15 @@ def _update_internalwell( self, well: InternalWell, path: str, show: bool ) -> N self.well_engine.remove( path ) return - points = self.__parse_polyline_node_coords( well.polyline_node_coords ) + points = self.__parse_polyline_property( well.polyline_node_coords, dtype=float ) + connectivity = self.__parse_polyline_property( well.polyline_segment_conn, dtype=int ) + connectivity = connectivity.flatten() + + sorted_points = [] + for id in connectivity: + sorted_points.append(points[id]) - well_polydata: pv.PolyData - well_polydata = pv.points.lines_from_points( points ) + well_polydata = pv.MultipleLines(sorted_points) index = self.well_engine.add_mesh( well_polydata, path ) tube_actor = self.plotter.add_mesh( self.well_engine.get_tube( index ) ) @@ -312,22 +320,26 @@ def _add_perforation( self, distance_from_head: float, path: str ) -> None: self._perforations[ path ] = saved_perforation - def __parse_polyline_node_coords( self, polyline_node_coords: str ): + def __parse_polyline_property( self, property: str, dtype : Type[Any] ) -> np.ndarray[Any]: """ - Internal method used to parse and convert a polyline_node_coords from an InternalWell. - This string always follow this for : "{ { 800, 1450, 395.646 }, { 800, 1450, -554.354 } }" + Internal method used to parse and convert a property, such as polyline_node_coords, from an InternalWell. + This string always follow this for : + "{ { 800, 1450, 395.646 }, { 800, 1450, -554.354 } }" """ - nodes_str = polyline_node_coords.split( "}, {" ) - nodes_str[ 0 ] = nodes_str[ 0 ].replace( " ", "" ) - nodes_str[ 0 ] = nodes_str[ 0 ].replace( "{", "" ) - nodes_str[ 1 ] = nodes_str[ 1 ].replace( " ", "" ) - nodes_str[ 1 ] = nodes_str[ 1 ].replace( "}", "" ) + try: + nodes_str = property.split( "}, {" ) + points = [] + for i in range(0, len(nodes_str)): + + nodes_str[ i ] = nodes_str[ i ].replace( " ", "" ) + nodes_str[ i ] = nodes_str[ i ].replace( "{", "" ) + nodes_str[ i ] = nodes_str[ i ].replace( "}", "" ) + + point = np.array( nodes_str[ i ].split( "," ), dtype=dtype ) - top_point = np.array( nodes_str[ 0 ].split( "," ), dtype=float ) - bottom_point = np.array( nodes_str[ 1 ].split( "," ), dtype=float ) + points.append( point ) - points = [] - points.append( top_point ) - points.append( bottom_point ) + return np.array(points,dtype=dtype) + except ValueError: + raise GeosTrameException("cannot be able to convert the property into a numeric array: ", ValueError) - return points diff --git a/geos-trame/tests/data/geosDeck/geosDeck.xml b/geos-trame/tests/data/geosDeck/geosDeck.xml new file mode 100644 index 000000000..5f5705978 --- /dev/null +++ b/geos-trame/tests/data/geosDeck/geosDeck.xml @@ -0,0 +1,362 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + From 67ae8930bb9e1c9d9af41c7a38a9d64feb4c4a18 Mon Sep 17 00:00:00 2001 From: Lucas Givord Date: Thu, 15 May 2025 13:23:54 +0200 Subject: [PATCH 4/7] feat(alert) : add alert system Adding an alert system to notify user about error. It is used in the viewer.py to notify the user about missing dataset mandaotry to read a well or a perforation --- geos-trame/src/geos_trame/app/__main__.py | 4 + geos-trame/src/geos_trame/app/core.py | 10 +- .../src/geos_trame/app/ui/alertHandler.py | 97 +++++++++++++++++++ geos-trame/src/geos_trame/app/ui/inspector.py | 57 ++++++----- .../src/geos_trame/app/ui/viewer/viewer.py | 37 +++++-- geos-trame/tests/test_file_handling.py | 2 +- 6 files changed, 168 insertions(+), 39 deletions(-) create mode 100644 geos-trame/src/geos_trame/app/ui/alertHandler.py diff --git a/geos-trame/src/geos_trame/app/__main__.py b/geos-trame/src/geos_trame/app/__main__.py index 149985a85..238394aee 100644 --- a/geos-trame/src/geos_trame/app/__main__.py +++ b/geos-trame/src/geos_trame/app/__main__.py @@ -33,3 +33,7 @@ def main( server=None, **kwargs ): app = GeosTrame( server, file_name ) app.server.start( **kwargs ) + + +if __name__ == "__main__": + main() diff --git a/geos-trame/src/geos_trame/app/core.py b/geos-trame/src/geos_trame/app/core.py index a1a5fcf82..a059c62cc 100644 --- a/geos-trame/src/geos_trame/app/core.py +++ b/geos-trame/src/geos_trame/app/core.py @@ -14,6 +14,7 @@ from geos_trame.app.ui.plotting import DeckPlotting from geos_trame.app.ui.timeline import TimelineEditor from geos_trame.app.ui.viewer.viewer import DeckViewer +from geos_trame.app.ui.alertHandler import AlertHandler import sys @@ -114,6 +115,8 @@ def build_ui( self, *args, **kwargs ): with VAppLayout( self.server ) as layout: self.simput_widget.register_layout( layout ) + self.alertHandler = AlertHandler() + def on_tab_change( tab_idx ): pass @@ -174,8 +177,11 @@ def on_tab_change( tab_idx ): if self.tree.input_file is not None: self.deck_ui() else: - + self.ctrl.on_add_error( + "Error", + "The file " + self.state.input_file + " cannot be parsed.", + ) print( - "Cannot build ui as the input file cannot be parse.", + "The file " + self.state.input_file + " cannot be parsed.", file=sys.stderr, ) diff --git a/geos-trame/src/geos_trame/app/ui/alertHandler.py b/geos-trame/src/geos_trame/app/ui/alertHandler.py new file mode 100644 index 000000000..b3d416468 --- /dev/null +++ b/geos-trame/src/geos_trame/app/ui/alertHandler.py @@ -0,0 +1,97 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright 2023-2024 TotalEnergies. +# SPDX-FileContributor: Lucas Givord - Kitware +import asyncio + +from trame.widgets import vuetify3 + + +class AlertHandler( vuetify3.VContainer ): + """ + Vuetify component used to display an alert status. + + This alert will be displayed in the bottom right corner of the screen. + It will be displayed until closed by the user or after 10 seconds if it is a success or warning. + """ + + def __init__( self ): + super().__init__( + fluid=True, + classes="pa-0 ma-0", + ) + + self.__max_number_of_status = 5 + self.__lifetime_of_alert = 10.0 + self._status_id = 0 + + self.state.alerts = [] + + self.server.controller.on_add_error.add_task( self.add_error ) + self.server.controller.on_add_warning.add_task( self.add_warning ) + + self.generate_alert_ui() + + def generate_alert_ui( self ): + """ + Generate the alert UI. + + The alert will be displayed in the bottom right corner of the screen. + + Use an abritary z-index value to put the alert on top of the other components. + """ + with self: + with vuetify3.VCol( style="width: 40%; position: fixed; right: 50px; bottom: 50px; z-index: 100;", ): + vuetify3.VAlert( + style="max-height: 20vh; overflow-y: auto", + classes="ma-2", + v_for=( "(status, index) in alerts", ), + key="status", + type=( "status.type", "info" ), + text=( "status.message", "" ), + title=( "status.title", "" ), + closable=True, + click_close=( self.on_close, f"[status.id]" ), + ) + + def add_alert( self, type: str, title: str, message: str ): + """ + Add a status to the stack with a unique id. + If there are more than 5 alerts displayed, remove the oldest. + A warning will be automatically closed after 10 seconds. + """ + self.state.alerts.append( { + "id": self._status_id, + "type": type, + "title": title, + "message": message, + } ) + + if len( self.state.alerts ) > self.__max_number_of_status: + self.state.alerts.pop( 0 ) + + alert_id = self._status_id + self._status_id += 1 + self.state.dirty( "alerts" ) + self.state.flush() + + if type == "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 ): + """ + Add an alert of type "warning" + """ + self.add_alert( "warning", title, message ) + + async def add_error( self, title: str, message: str ): + """ + Add an alert of type "error" + """ + self.add_alert( "error", title, message ) + + def on_close( self, alert_id ): + """ + 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 ) ) + self.state.flush() diff --git a/geos-trame/src/geos_trame/app/ui/inspector.py b/geos-trame/src/geos_trame/app/ui/inspector.py index 2d8712798..309ba0994 100644 --- a/geos-trame/src/geos_trame/app/ui/inspector.py +++ b/geos-trame/src/geos_trame/app/ui/inspector.py @@ -7,6 +7,7 @@ import yaml from pydantic import BaseModel from trame.widgets import vuetify3 as vuetify +from trame.widgets import html from trame_simput import get_simput_manager from typing import Any @@ -181,20 +182,15 @@ def __init__( self, listen_to_active=True, source=None, **kwargs ): **{ # style "hoverable": True, + "max_width": 500, "rounded": True, - # "dense": True, - # "density": "compact", - # "active_color": "blue", # activation logic - # "activatable": True, - # "active_strategy": "single-independent", - # "activated": ("active_ids", ), - # "update_activated": "(active_ids) => {active_id = active_ids[0]}", + "activatable": True, + "activated": ( "active_ids", ), + "active_strategy": "single-independent", + "update_activated": ( self.change_current_id, "$event" ), # selection logic - "selectable": True, - "select_strategy": "single-independent", - "selected": ( "active_ids", ), - "update_selected": "(active_ids) => {active_id = active_ids[0]}", + "selectable": False, **kwargs, }, ) @@ -226,18 +222,17 @@ def on_change( topic, ids=None, **kwargs ): with self: with vuetify.Template( v_slot_append="{ item }" ): - vuetify.VCheckboxBtn( - v_if="item.is_drawable", - icon=True, - true_icon="mdi-eye", - false_icon="mdi-eye-off", - dense=True, - hide_details=True, - update_modelValue=( self.to_draw_change, "[ item.id, $event ] " ), - ) - - def to_draw_change( self, id, drawn ): - self.state.object_state = [ id, drawn ] + vuetify.VCheckboxBtn( v_if="item.is_drawable", + focused=True, + dense=True, + hide_details=True, + icon=True, + false_icon="mdi-eye-off", + true_icon="mdi-eye", + update_modelValue=( self.to_draw_change, "[ item, item.id, $event ] " ) ) + + def to_draw_change( self, item, item_id, drawn ): + self.state.object_state = [ item_id, drawn ] @property def source( self ): @@ -299,10 +294,14 @@ def set_source( self, v ): debug.set_property( key, getattr( active_block, key ) ) debug.commit() - # def on_active_change(self, **_): - # if self.listen_to_active: - # print("on_active_change") - # self.set_source_proxy(simple.GetActiveSource()) + def change_current_id( self, item_id=None ): + """ + Change the current id of the tree. + This function is called when the user click on the tree. + """ + if item_id is None: + # Silently ignore, it could occurs is the user click on the tree + # and this item is already selected + return - # def on_selection_change(self, node_active, **_): - # print("on_selection_change", node_active) + self.state.active_id = item_id 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 bdf9fb1a1..b5d11acdf 100644 --- a/geos-trame/src/geos_trame/app/ui/viewer/viewer.py +++ b/geos-trame/src/geos_trame/app/ui/viewer/viewer.py @@ -114,12 +114,33 @@ def update_viewer( self, object_state: list[ str, bool ], **kwargs ) -> None: self._update_vtkmesh( active_block, show_obj ) if isinstance( active_block, Vtkwell ): + 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 + self._update_vtkwell( active_block, path, show_obj ) if isinstance( active_block, InternalWell ): + 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 + self._update_internalwell( active_block, path, show_obj ) if isinstance( active_block, Perforation ): + if self.well_engine.get_number_of_wells() == 0 and show_obj: + self.ctrl.on_add_warning( + "Can't display " + active_block.name, + "Please display a well before creating a perforation", + ) + return self._update_perforation( active_block, show_obj, path ) def _on_clip_visibility_change( self, **kwargs ): @@ -212,9 +233,9 @@ def _update_internalwell( self, well: InternalWell, path: str, show: bool ) -> N sorted_points = [] for id in connectivity: - sorted_points.append(points[id]) + sorted_points.append( points[ id ] ) - well_polydata = pv.MultipleLines(sorted_points) + well_polydata = pv.MultipleLines( sorted_points ) index = self.well_engine.add_mesh( well_polydata, path ) tube_actor = self.plotter.add_mesh( self.well_engine.get_tube( index ) ) @@ -320,7 +341,7 @@ def _add_perforation( self, distance_from_head: float, path: str ) -> None: self._perforations[ path ] = saved_perforation - def __parse_polyline_property( self, property: str, dtype : Type[Any] ) -> np.ndarray[Any]: + def __parse_polyline_property( self, property: str, dtype: Type[ Any ] ) -> np.ndarray[ Any ]: """ Internal method used to parse and convert a property, such as polyline_node_coords, from an InternalWell. This string always follow this for : @@ -329,7 +350,7 @@ def __parse_polyline_property( self, property: str, dtype : Type[Any] ) -> np.nd try: nodes_str = property.split( "}, {" ) points = [] - for i in range(0, len(nodes_str)): + for i in range( 0, len( nodes_str ) ): nodes_str[ i ] = nodes_str[ i ].replace( " ", "" ) nodes_str[ i ] = nodes_str[ i ].replace( "{", "" ) @@ -339,7 +360,9 @@ def __parse_polyline_property( self, property: str, dtype : Type[Any] ) -> np.nd points.append( point ) - return np.array(points,dtype=dtype) + return np.array( points, dtype=dtype ) except ValueError: - raise GeosTrameException("cannot be able to convert the property into a numeric array: ", ValueError) - + raise GeosTrameException( + "cannot be able to convert the property into a numeric array: ", + ValueError, + ) diff --git a/geos-trame/tests/test_file_handling.py b/geos-trame/tests/test_file_handling.py index e8e8668ee..2cbf4c2cc 100644 --- a/geos-trame/tests/test_file_handling.py +++ b/geos-trame/tests/test_file_handling.py @@ -14,4 +14,4 @@ def test_unsupported_file( capsys ): GeosTrame( server, file_name ) captured = capsys.readouterr() - assert captured.err == "Cannot build ui as the input file cannot be parse.\n" + assert captured.err == "The file tests/data/acous3D/acous3D_vtu.xml cannot be parsed.\n" From 7d25e8b616d34f2f3fec1d671b34b3e8b3a07a05 Mon Sep 17 00:00:00 2001 From: Lucas Givord Date: Tue, 20 May 2025 11:40:56 +0200 Subject: [PATCH 5/7] fix(IO): support file which doesn't contain an Include tag --- geos-trame/src/geos_trame/app/deck/tree.py | 6 ++++-- geos-trame/src/geos_trame/app/io/xml_parser.py | 12 ++++++++++++ 2 files changed, 16 insertions(+), 2 deletions(-) diff --git a/geos-trame/src/geos_trame/app/deck/tree.py b/geos-trame/src/geos_trame/app/deck/tree.py index 0513197db..708bf34b5 100644 --- a/geos-trame/src/geos_trame/app/deck/tree.py +++ b/geos-trame/src/geos_trame/app/deck/tree.py @@ -160,10 +160,12 @@ def write_files( self ): files = self._split( pb ) for filepath, content in files.items(): - includeName: str = self.input_file.xml_parser.file_to_relative_path[ filepath ] model_loaded: BaseModel = self.decode_data( content ) model_with_changes: BaseModel = self._apply_changed_properties( model_loaded ) - self._append_include_file( model_with_changes, includeName ) + + if self.input_file.xml_parser.contains_include_files(): + includeName: str = self.input_file.xml_parser.get_relative_path_of_file( filepath ) + self._append_include_file( model_with_changes, includeName ) model_as_xml: str = self.to_xml( model_with_changes ) diff --git a/geos-trame/src/geos_trame/app/io/xml_parser.py b/geos-trame/src/geos_trame/app/io/xml_parser.py index 7f23d4919..a5e9a5958 100644 --- a/geos-trame/src/geos_trame/app/io/xml_parser.py +++ b/geos-trame/src/geos_trame/app/io/xml_parser.py @@ -61,6 +61,18 @@ def get_simulation_deck( self ) -> ElementTree.Element: return return self.simulation_deck + def contains_include_files( self ) -> bool: + """ + Return True if the parsed file contains included file or not. + """ + return len( self.file_to_relative_path ) > 0 + + def get_relative_path_of_file( self, filename: str ) -> str: + """ + Return the relative path of a given filename. + """ + return self.file_to_relative_path[ filename ] + def _read( self ) -> ElementTree.Element: """Reads an xml file (and recursively its included files) into memory From d29d60fcb5820010d2204c1fd36d5221a8479609 Mon Sep 17 00:00:00 2001 From: Lucas Givord Date: Fri, 23 May 2025 08:54:14 +0200 Subject: [PATCH 6/7] test(well): check well intersection feature for InternalWell andVtkmesh --- .../tests/data/geosDeck/P4_launcher.sbatch | 31 ++++++++ geos-trame/tests/data/geosDeck/co2flash.txt | 1 + geos-trame/tests/data/geosDeck/geosDeck.xml | 2 +- geos-trame/tests/data/geosDeck/pvtgas.txt | 2 + geos-trame/tests/data/geosDeck/pvtliquid.txt | 2 + geos-trame/tests/data/geosDeck/well_1.vtk | Bin 0 -> 586 bytes geos-trame/tests/test_well_intersection.py | 74 ++++++++++++++++++ 7 files changed, 111 insertions(+), 1 deletion(-) create mode 100644 geos-trame/tests/data/geosDeck/P4_launcher.sbatch create mode 100644 geos-trame/tests/data/geosDeck/co2flash.txt create mode 100644 geos-trame/tests/data/geosDeck/pvtgas.txt create mode 100644 geos-trame/tests/data/geosDeck/pvtliquid.txt create mode 100644 geos-trame/tests/data/geosDeck/well_1.vtk create mode 100644 geos-trame/tests/test_well_intersection.py diff --git a/geos-trame/tests/data/geosDeck/P4_launcher.sbatch b/geos-trame/tests/data/geosDeck/P4_launcher.sbatch new file mode 100644 index 000000000..9f14e7cd9 --- /dev/null +++ b/geos-trame/tests/data/geosDeck/P4_launcher.sbatch @@ -0,0 +1,31 @@ +#!/bin/sh + +#SBATCH --job-name="geos" +#SBATCH --ntasks=8 +#SBATCH --nodes=1 +#SBATCH --time=3:00:00 +#SBATCH --partition=p4_general +##SBATCH --exclusive +#SBATCH --output=job_GEOS_P4_%j.out +#SBATCH --error=job_GEOS_P4_%j.err +##SBATCH --mem=734GB + +# do not change +ulimit -s unlimited +ulimit -c unlimited + +# loading of the module +module purge +module use /workrd/SCR/NUM/GEOS_environment/p4/modulefiles/app +module load geos/develop_3bf12d2/pangea4-gcc12.1-hpcxompi2.17.1-onemkl2023.2.0-Release-2024-06-20-10-05 + +export HDF5_USE_FILE_LOCKING=FALSE +export OMP_NUM_THREADS=1 + +#----- Set standard Output and standrard error base file name. ----- +OUT_NAME=job_GEOS_P4_${SLURM_JOBID} + +srun --mpi=pmix_v3 --hint=nomultithread \ + -n ${SLURM_NTASKS} geos \ + -o modelA \ + -i simulationA.xml \ No newline at end of file diff --git a/geos-trame/tests/data/geosDeck/co2flash.txt b/geos-trame/tests/data/geosDeck/co2flash.txt new file mode 100644 index 000000000..5c4a8ad9d --- /dev/null +++ b/geos-trame/tests/data/geosDeck/co2flash.txt @@ -0,0 +1 @@ +FlashModel CO2Solubility 1e5 1.5e8 5e4 283.15 383.15 1 0 diff --git a/geos-trame/tests/data/geosDeck/geosDeck.xml b/geos-trame/tests/data/geosDeck/geosDeck.xml index 5f5705978..83ae4ba46 100644 --- a/geos-trame/tests/data/geosDeck/geosDeck.xml +++ b/geos-trame/tests/data/geosDeck/geosDeck.xml @@ -98,7 +98,7 @@ polylineNodeCoords="{ { 800, 1450, 395.646 }, { 800, 1450, -200.0 }, { 800, 1450, 0.0 }, - { 800, 1450, -554.354 } }" + { 800, 1450, -554.354 } }" polylineSegmentConn="{ { 0, 2 }, { 2, 1 }, { 1, 3 } }" radius="0.050" numElementsPerSegment="5"> diff --git a/geos-trame/tests/data/geosDeck/pvtgas.txt b/geos-trame/tests/data/geosDeck/pvtgas.txt new file mode 100644 index 000000000..5d59a368e --- /dev/null +++ b/geos-trame/tests/data/geosDeck/pvtgas.txt @@ -0,0 +1,2 @@ +DensityFun SpanWagnerCO2Density 1e5 1.5e8 5e4 283.15 383.15 1 +ViscosityFun FenghourCO2Viscosity 1e5 1.5e8 5e4 283.15 383.15 1 diff --git a/geos-trame/tests/data/geosDeck/pvtliquid.txt b/geos-trame/tests/data/geosDeck/pvtliquid.txt new file mode 100644 index 000000000..8363302af --- /dev/null +++ b/geos-trame/tests/data/geosDeck/pvtliquid.txt @@ -0,0 +1,2 @@ +DensityFun PhillipsBrineDensity 1e5 1.5e8 5e4 283.15 383.15 1 0 +ViscosityFun PhillipsBrineViscosity 0 diff --git a/geos-trame/tests/data/geosDeck/well_1.vtk b/geos-trame/tests/data/geosDeck/well_1.vtk new file mode 100644 index 0000000000000000000000000000000000000000..fdc61e53809d64259b71c8d25eb8ba53daf0452f GIT binary patch literal 586 zcmaKoO-sWt9LD3@g!m-_;$;WfhT7Xkm!=Ttn$l*&okpQi%DQ!Qndr?=;Z1LX)Wf{^ z1$@J^-(`1`tsUrL3FLY5{``N3=t>Te70S?#6M=dno5txFogUYr-biP1GLx`PUDNMF z%j9N2IP#d&*B*L|x;#Jzx=7McLi`PYH3tBH{=BUH$U7)~`+hMj{oBK9tMu=q`C{$A zwjWFX0q>q*hj4TG8Fg*ucT7&1i)s)$)FlCGAOkYnR;dDAiM*MJcr4FOAo$z%A!e>i zFsD7r`x}pL6*spF-6?dp(7i${5EI84=xnGCZAu&q*|A6>O;fF?I4p}V<8hc^kt8&l zqPps8nMPdYTt-|WTs>kyPR@DPCj-owZz<+nRmjD)oMqyuQNwAPMd~>zrmF50@*)dp yR`Xo@W}ZK5ZRtU^R<)RSyS?T7R*wK|&g-QL;wrH5FR;3+0ckiIjE16tRNya0(~Z#p literal 0 HcmV?d00001 diff --git a/geos-trame/tests/test_well_intersection.py b/geos-trame/tests/test_well_intersection.py new file mode 100644 index 000000000..f45841da8 --- /dev/null +++ b/geos-trame/tests/test_well_intersection.py @@ -0,0 +1,74 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright 2023-2024 TotalEnergies. +# SPDX-FileContributor: Lucas Givord - Kitware +from trame.app import get_server +from trame_client.utils.testing import enable_testing +from geos_trame.app.core import GeosTrame + + +def test_internal_well_intersection(): + + server = enable_testing( get_server( client_type="vue3" ), "message" ) + file_name = "geos-trame/tests/data/geosDeck/geosDeck.xml" + + app = GeosTrame( server, 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/Mesh/0/VTKMesh/0/InternalWell/0", + True, + ] + app.deckInspector.state.flush() + + app.deckInspector.state.object_state = [ + "Problem/Mesh/0/VTKMesh/0/InternalWell/0/Perforation/0", + True, + ] + app.deckInspector.state.flush() + + app.deckInspector.state.object_state = [ + "Problem/Mesh/0/VTKMesh/0/InternalWell/0/Perforation/1", + True, + ] + app.deckInspector.state.flush() + + assert app.deckViewer.well_engine.get_number_of_wells() == 1 + assert len( app.deckViewer._perforations ) == 2 + + +def test_vtk_well_intersection(): + + server = enable_testing( get_server( client_type="vue3" ), "message" ) + file_name = "geos-trame/tests/data/geosDeck/geosDeck.xml" + + app = GeosTrame( server, 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/Mesh/0/VTKMesh/0/VTKWell/0", True ] + app.deckInspector.state.flush() + + app.deckInspector.state.object_state = [ + "Problem/Mesh/0/VTKMesh/0/VTKWell/0/Perforation/0", + True, + ] + app.deckInspector.state.flush() + + app.deckInspector.state.object_state = [ + "Problem/Mesh/0/VTKMesh/0/VTKWell/0/Perforation/1", + True, + ] + app.deckInspector.state.flush() + + assert app.deckViewer.well_engine.get_number_of_wells() == 1 + assert len( app.deckViewer._perforations ) == 2 + + app.deckInspector.state.object_state = [ "Problem/Mesh/0/VTKMesh/0/VTKWell/0", False ] + app.deckInspector.state.flush() + + assert app.deckViewer.well_engine.get_number_of_wells() == 0 From 4b4c3844fc23d83b95ed9fac6077dd4c0ed844cb Mon Sep 17 00:00:00 2001 From: Lucas Givord Date: Fri, 23 May 2025 09:13:44 +0200 Subject: [PATCH 7/7] test(well) : reuse singlePhaseFlow dataset To avoid LFS storage limit, test_well_intersection will reuse the mesh from the other deck xml. --- geos-trame/tests/data/geosDeck/geosDeck.xml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/geos-trame/tests/data/geosDeck/geosDeck.xml b/geos-trame/tests/data/geosDeck/geosDeck.xml index 83ae4ba46..a4a7de8ef 100644 --- a/geos-trame/tests/data/geosDeck/geosDeck.xml +++ b/geos-trame/tests/data/geosDeck/geosDeck.xml @@ -64,9 +64,10 @@ +