diff --git a/src/fourc_webviewer/fourc_webserver.py b/src/fourc_webviewer/fourc_webserver.py index c8ebce3..61c14ba 100644 --- a/src/fourc_webviewer/fourc_webserver.py +++ b/src/fourc_webviewer/fourc_webserver.py @@ -3,7 +3,9 @@ web viewer.""" import copy +import os import re +import shutil import tempfile from pathlib import Path @@ -11,6 +13,7 @@ import pyvista as pv from fourcipp import CONFIG from fourcipp.fourc_input import FourCInput, ValidationError +from fourcipp.utils.yaml_io import load_yaml from loguru import logger from trame.app import get_server from trame.decorators import TrameApp, change, controller @@ -35,6 +38,7 @@ ) from fourc_webviewer.read_geometry_from_file import ( FourCGeometry, + get_geometry_file, ) # Global variable @@ -44,6 +48,8 @@ # always set pyvista to plot off screen with Trame pv.OFF_SCREEN = True +pv.set_error_output_file("vtk_errors.log") + @TrameApp() class FourCWebServer: @@ -66,6 +72,10 @@ def __init__( self.server = get_server() + # initialize include upload value: False (bottom sheet with include upload is not displayed until there is a fourcyaml file uploaded) + self.state.include_upload_open = False + self.state.included_files = [] + # declare server-side variable dict: variables which should not # be exposed to the client-side self._server_vars = {} @@ -118,19 +128,13 @@ def __init__( # initialize state object self.init_state_and_server_vars() - # convert file to vtu and create dedicated render objects - fourc_geometry = FourCGeometry( - fourc_yaml_file=fourc_yaml_file, - temp_dir=Path(self._server_vars["temp_dir_object"].name), - first_render=True, - ) - self.state.vtu_path = fourc_geometry.vtu_file_path + if "render_window" not in self._server_vars: + self._server_vars["render_window"] = pv.Plotter() + self.state.vtu_path = "" - if self.state.vtu_path == "": - self.state.read_in_status = self.state.all_read_in_statuses[ - "vtu_conversion_error" - ] - self.init_pyvista_render_objects() + # self._server_vars["fourc_yaml_file_dir"] is a Path to the parent directory of the initial file. + # This is not always connected to the currently open file. + self._server_vars["fourc_yaml_file_dir"] = Path(fourc_yaml_file).parent # create ui create_gui(self.server, self._server_vars["render_window"]) @@ -266,19 +270,65 @@ def init_pyvista_render_objects(self): The saved vtu file path is hereby utilized. """ + # convert file to vtu and create dedicated render objects + if not ( + Path(self._server_vars["temp_dir_object"].name) + / self._server_vars["fourc_yaml_name"] + ).exists(): + raise Exception( + "File does not exist: " + + self._server_vars["temp_dir_object"].name + + "/" + + self._server_vars["fourc_yaml_name"] + ) + + geometry_file_name = get_geometry_file(self._server_vars["fourc_yaml_content"])[ + 0 + ] + + if geometry_file_name: + # ensure that geometry_file_name really only contains the name and not a path + geometry_file_name = Path(geometry_file_name).name + if not ( + Path(self._server_vars["temp_dir_object"].name) / geometry_file_name + ).exists(): + # if the current yaml file references a geometry file it will have already been loaded into the temp dir by now. + # if not something went wrong + raise Exception( + "File does not exist: " + + self._server_vars["temp_dir_object"].name + + "/" + + geometry_file_name + ) + + temp_yaml_path = ( + Path(self._server_vars["temp_dir_object"].name) + / self._server_vars["fourc_yaml_name"] + ) + # creates the FourCGeometry. By now every used file has to be in the temp dir + fourc_geometry = FourCGeometry( + fourc_yaml_file=temp_yaml_path, + temp_dir=Path(self._server_vars["temp_dir_object"].name), + ) + self.state.vtu_path = fourc_geometry.vtu_file_path # render window initialization: to be done only once while starting the webviewer, otherwise no proper binding within the current setup! if "render_window" not in self._server_vars: self._server_vars["render_window"] = pv.Plotter() - self._server_vars["render_window"].clear_actors() + if self.state.vtu_path == "": + self.state.read_in_status = self.state.all_read_in_statuses[ + "vtu_conversion_error" + ] + self._server_vars["render_window"].clear_actors() # read problem mesh problem_mesh = pv.read(self.state.vtu_path) - - # save problem mesh as actor - self._actors["problem_mesh"] = self._server_vars["render_window"].add_mesh( - problem_mesh, color="bisque", opacity=0.2, render=False + # get problem mesh. add_mesh returns an actor, so we have to get the actors mesh + self._actors["problem_mesh"] = ( + self._server_vars["render_window"] + .add_mesh(problem_mesh, color="bisque", opacity=0.2, render=False) + .mapper.dataset ) # get mesh of the selected material @@ -338,6 +388,12 @@ def init_pyvista_render_objects(self): all_result_descriptions = self.state.result_description_section.keys() for dc in all_result_descriptions: + if ( + not self.state.result_description_section[dc] + .get("PARAMETERS", {}) + .get("NODE") + ): + continue node_coords = problem_mesh.points[ self.state.result_description_section[dc]["PARAMETERS"]["NODE"] - 1, :, @@ -355,6 +411,8 @@ def init_pyvista_render_objects(self): ) self.update_pyvista_render_objects() + self._server_vars["render_window"].reset_camera() + def update_pyvista_render_objects(self): """Update/ initialize pyvista view objects (reader, thresholds, global COS, ...) for the rendered window. @@ -363,7 +421,7 @@ def update_pyvista_render_objects(self): """ legend_items = [] - for dc in self._actors["result_description_nodes"].values(): + for dc in self._actors.get("result_description_nodes", {}).values(): dc.SetVisibility(False) if ( self.state.selected_main_section_name == "RESULT DESCRIPTION" @@ -376,7 +434,7 @@ def update_pyvista_render_objects(self): ].SetVisibility(True) legend_items.append(("Selected result description", "deepskyblue")) - for rd in self._actors["dc_geometry_entities"].values(): + for rd in self._actors.get("dc_geometry_entities", {}).values(): rd.SetVisibility(False) if ( self.state.selected_main_section_name == "DESIGN CONDITIONS" @@ -388,7 +446,7 @@ def update_pyvista_render_objects(self): ].SetVisibility(True) legend_items.append(("Selected design condition", "navy")) - for mat in self._actors["material_meshes"].values(): + for mat in self._actors.get("material_meshes", {}).values(): mat.SetVisibility(False) if ( self.state.selected_material @@ -748,7 +806,9 @@ def init_result_description_state_and_server_vars(self): # get result description section result_description_section = copy.deepcopy( - self._server_vars["fourc_yaml_content"]["RESULT DESCRIPTION"] + self._server_vars["fourc_yaml_content"].sections.get( + "RESULT DESCRIPTION", {} + ) ) # initialize empty dict as the result description section @@ -961,6 +1021,75 @@ def init_funct_state_and_server_vars(self): 6 # precision for the user input of the values defined above: x, y, z and t_max ) + def append_include_files(self, file_paths): + """Appends list of files to the included files input field. + + They will be uploaded before the user can edit or view the file. + """ + # get the file names of the needed files. These names will be shown in the pop up window. + yaml_include_names = [Path(file_path).name for file_path in file_paths] + included_files = copy.deepcopy(self.state.included_files) + # make a copy, so the state triggers reactivity + for include_name in yaml_include_names: + # this file path is created to check weather the needed file is already present on the server. + # If the user is, for example, continuously working on a file that references an exodus file + # they can copy it into the server file directory and it will be opened automatically + # without prompting the user to upload the .exo file every time. + include_file_server = Path( + self._server_vars["fourc_yaml_file_dir"], + include_name, + ) + # every file the user is working on will be loaded into the temp directory. + # This is because the FourCGeometry Constructor requires the .yaml file and the .exo file to be in the same directory. + include_temp_path = Path( + self._server_vars["temp_dir_object"].name, + include_name, + ) + + # if the file has been copied into the server directory it will be loaded into the temp dir automatically + # without prompting the user every time they open the .yaml file. + if include_file_server.is_file(): + shutil.copyfile(include_file_server, include_temp_path) + elif not include_temp_path.is_file(): + # This is the standard case. The file is not present on the server and the user is prompted to upload it. + included_files.append( + { + "name": include_name, + "uploaded": False, + "error": None, + "content": None, + } + ) + # trigger reactivity + self.state.included_files = included_files + + def request_included_files(self): + """Requests the included files from the user by opening a the include + files dialog and setting up the state variable accordingly.""" + + self.state.included_files = [] + # if the uploaded .yaml file contains a reference to a geometry file, this variable will be it's name. + # otherwise it will be None + geometry_file_name = get_geometry_file(self._server_vars["fourc_yaml_content"])[ + 0 + ] + + if geometry_file_name: + self.append_include_files([geometry_file_name]) + # add yaml includes + yaml_include_names = [ + Path(file_path).name + for file_path in self._server_vars.get("fourc_yaml_content").sections.get( + "INCLUDES", [] + ) + ] + self.append_include_files(yaml_include_names) + + if self.state.included_files: + self.state.include_upload_open = True + else: + self.confirm_included_files() + def sync_funct_section_from_state(self): """Syncs the server-side functions section based on the current values of the dedicated state variables.""" @@ -1032,12 +1161,22 @@ def init_mode_state_vars(self): def change_fourc_yaml_file(self, fourc_yaml_file, **kwargs): """Reaction to change of state.fourc_yaml_file.""" + if not fourc_yaml_file or fourc_yaml_file["name"].split(".")[-1] not in [ + "yaml", + "yml", + "DAT", + "dat", + ]: + logger.warning( + "File does not have a .yml / .yaml / .dat / .DAT ending or is empty. Try opening another file." + ) + return # create temporary fourc yaml file from the content of the given file temp_fourc_yaml_file = Path( self._server_vars["temp_dir_object"].name, fourc_yaml_file["name"] ) - with open(temp_fourc_yaml_file, "w") as f: - f.writelines(fourc_yaml_file["content"].decode("utf-8")) + with open(temp_fourc_yaml_file, "wb") as f: + f.write(fourc_yaml_file["content"]) # read content, lines and other details of the given file ( @@ -1049,12 +1188,72 @@ def change_fourc_yaml_file(self, fourc_yaml_file, **kwargs): ) = read_fourc_yaml_file(temp_fourc_yaml_file) self._server_vars["fourc_yaml_name"] = Path(temp_fourc_yaml_file).name - # set vtu file path empty to make the convert button visible - # (only if the function was not run yet, i.e., after the - # initial rendering) - self._server_vars["render_count"]["change_fourc_yaml_file"] += 1 - if self._server_vars["render_count"]["change_fourc_yaml_file"] > 1: - self.state.vtu_path = "" + if self._server_vars["fourc_yaml_read_in_status"]: + self.state.read_in_status = self.state.all_read_in_statuses["success"] + else: + self.state.read_in_status = self.state.all_read_in_statuses[ + "validation_error" + ] + + self._server_vars["fourc_yaml_name"] = temp_fourc_yaml_file.name + + self.request_included_files() + + @controller.set("on_upload_include_file") + def on_upload_include_file(self, uploaded_file, index, **kwargs): + """Gets called when an included file is uploaded. + + Saves the uploaded file into the state variable. + """ + self.state.included_files[index]["content"] = uploaded_file + + if uploaded_file["name"].split(".")[-1] in ["yaml", "yml"]: + content = ( + load_yaml(uploaded_file.get("content", {}).get("content", "")) or {} + ) + yaml_include_names = [ + Path(file_path).name for file_path in content.get("INCLUDES", []) + ] + self.append_include_files(yaml_include_names) + + try: + if self.state.included_files[index]["name"] != uploaded_file["name"]: + self.state.included_files[index]["error"] = ( + "File name mismatch. Expected: " + + self.state.included_files[index]["name"] + ) + elif self.state.included_files[index]["content"]["size"] == 0: + self.state.included_files[index]["error"] = "File is empty." + else: + self.state.included_files[index]["error"] = None + self.state.included_files[index]["uploaded"] = True + except Exception: + self.state.included_files[index]["error"] = "Please upload a file." + self.state.included_files[index]["uploaded"] = False + self.state.dirty("included_files") + self.state.flush() + + @controller.set("confirm_included_files") + def confirm_included_files(self, **kwargs): + """Gets called when the Accept button in the included files dialog is + pressed. + + Saves all files into the temporary directory. + """ + self.state.include_upload_open = False + + for included_file in self.state.included_files: + # create file in temp directory + included_file_path = Path( + self._server_vars["temp_dir_object"].name, + included_file["content"]["name"], + ) + with open(included_file_path, "wb") as f: + f.write(included_file["content"]["content"]) + + self.init_state_and_server_vars() + + self.init_pyvista_render_objects() @change("export_fourc_yaml_path") def change_export_fourc_yaml_path(self, export_fourc_yaml_path, **kwargs): @@ -1250,10 +1449,14 @@ def change_selected_result_description_id( @change("selected_funct") def change_selected_funct(self, selected_funct, **kwargs): """Reaction to change of state.selected_funct.""" + # if there is no function_section + if not self.state.funct_section.get(selected_funct, {}): + return + # set the selected funct item to the first within the newly # selected funct self.state.selected_funct_item = next( - iter(self.state.funct_section[selected_funct]) + iter(self.state.funct_section.get(selected_funct, {})), ) # update plotly figure @@ -1278,18 +1481,22 @@ def change_selected_funct_item(self, selected_funct_item, **kwargs): def change_funct_plot(self, funct_plot, **kwargs): """Reaction to change of state.funct_plot.""" # update plotly figure - if self.state.funct_section[self.state.selected_funct][ - self.state.selected_funct_item - ]["VISUALIZATION"]: + if ( + self.state.funct_section.get(self.state.selected_funct, {}) + .get(self.state.selected_funct_item, {}) + .get("VISUALIZATION") + ): self.server.controller.figure_update(function_plot_figure(self.state)) @change("funct_section") def change_funct_section(self, funct_section, **kwargs): """Reaction to change of state.funct_section.""" # update plotly figure - if self.state.funct_section[self.state.selected_funct][ - self.state.selected_funct_item - ]["VISUALIZATION"]: + if ( + self.state.funct_section.get(self.state.selected_funct, {}) + .get(self.state.selected_funct_item, {}) + .get("VISUALIZATION") + ): self.server.controller.figure_update(function_plot_figure(self.state)) ################################################# @@ -1356,50 +1563,6 @@ def click_export_button(self, **kwargs): settings.""" self.state.export_mode = not self.state.export_mode - @controller.set("click_convert_button") - def click_convert_button(self, **kwargs): - """Convert the given fourc yaml file to vtu and run the state - initialization routines.""" - - # create temporary fourc yaml file from the content of the given file - temp_fourc_yaml_file = Path( - self._server_vars["temp_dir_object"].name, - self.state.fourc_yaml_file["name"], - ) - - with open(temp_fourc_yaml_file, "w") as f: - f.write(self.state.fourc_yaml_file["content"].decode("utf-8")) - - if self._server_vars["fourc_yaml_read_in_status"]: - self.state.read_in_status = self.state.all_read_in_statuses["success"] - - # initialize state object - self.init_state_and_server_vars() - - # convert to vtu - fourc_geometry = FourCGeometry( - fourc_yaml_file=temp_fourc_yaml_file, - temp_dir=Path(self._server_vars["temp_dir_object"].name), - ) - self.state.vtu_path = fourc_geometry.vtu_file_path - - # catch eventual conversion error - if self.state.vtu_path == "": - self.state.read_in_status = self.state.all_read_in_statuses[ - "vtu_conversion_error" - ] - else: - # reset view - self.init_pyvista_render_objects() - self._server_vars["render_window"].reset_camera() - self.ctrl.view_reset_camera() - self.ctrl.view_update() - - else: - self.state.read_in_status = self.state.all_read_in_statuses[ - "validation_error" - ] - @controller.set("click_save_button") def click_save_button(self, **kwargs): """Save the current content to a new fourc_yaml content.""" diff --git a/src/fourc_webviewer/gui_utils.py b/src/fourc_webviewer/gui_utils.py index 7654963..b72c0e0 100644 --- a/src/fourc_webviewer/gui_utils.py +++ b/src/fourc_webviewer/gui_utils.py @@ -143,14 +143,9 @@ def _toolbar(server_controller): vuetify.VFileInput( label="Input file", v_model=("fourc_yaml_file",), - update_modelValue="flushState('fourc_yaml_file')", + # update_modelValue="flushState('fourc_yaml_file')", accept=".yaml,.yml", ) - vuetify.VBtn( - text="CONVERT", - v_if=("vtu_path == ''",), - click=server_controller.click_convert_button, - ) vuetify.VBtn( text="INFO", outlined=True, @@ -242,6 +237,58 @@ def _bottom_sheet_export(server_controller): ) +def _bottom_sheet_include_upload(server): + """Bottom sheet layout (EXPORT mode).""" + + with vuetify.VDialog( + v_model=("include_upload_open",), persistent=True, max_width="600px" + ): + with vuetify.VCard(classes="pa-5"): + vuetify.VCardTitle("Upload Included Files") + + with vuetify.VCardText(): + with vuetify.VRow( + dense=True, + align="center", + v_for="(file, i) in included_files", + key=("included_files[i].name",), + ): + with vuetify.VCol(cols=11): + vuetify.VFileInput( + update_modelValue=( + server.controller.on_upload_include_file, + "[$event, i]", + ), + label=("file.name",), + multiple=False, + variant="outlined", + color=( + "file.error ? 'error' : file.uploaded ? 'success' : undefined", + ), + error_messages=("file.error",), + ) + with vuetify.VCol(cols=1): + vuetify.VIcon( + icon=( + "file.error || !file.uploaded ? 'mdi-alert-circle' : 'mdi-check-circle'", + ), + color=( + "file.error ? 'error' : file.uploaded ? 'success' : 'primary'", + ), + classes="mr-2 pb-5 pl-3", + size="36", + ) + with vuetify.VCardActions(classes="justify-end"): + vuetify.VBtn( + "Accept", + size="large", + color="primary", + disabled=("!included_files.every(f => !f.error && f.uploaded)",), + click=(server.controller.confirm_included_files,), + variant="text", + ) + + def _sections_dropdown(): """Section dropdown layout.""" vuetify.VSelect( @@ -789,8 +836,7 @@ def _prop_value_table(server): "|| json_schema['properties']?.[selected_section_name]?.['properties']?.[add_key]?.['type'] == 'integer')" "&& !json_schema['properties']?.[selected_section_name]?.['properties']?.[add_key]?.['enum']" ), - blur=server.controller.on_leave_edit_field, - update_modelValue="flushState('general_sections')", # this is required in order to flush the state changes correctly to the server, as our passed on v-model is a nested variable + update_modelValue="flushState('add_value')", # this is required in order to flush the state changes correctly to the server, as our passed on v-model is a nested variable classes="w-80 pb-1", dense=True, # If we will add errors for this later @@ -810,7 +856,7 @@ def _prop_value_table(server): vuetify.VSwitch( v_model=("add_value"), classes="mt-4", - update_modelValue="flushState('general_sections')", + update_modelValue="flushState('add_value')", class_="mx-100", dense=True, color="primary", @@ -822,7 +868,7 @@ def _prop_value_table(server): "json_schema['properties']?.[selected_section_name]" "?.['properties']?.[add_key]?.['enum']" ), - update_modelValue="flushState('general_sections')", + update_modelValue="flushState('add_value')", # bind the enum array as items items=( "json_schema['properties'][selected_section_name]['properties'][add_key]['enum']", @@ -1446,6 +1492,8 @@ def create_gui(server, render_window): _bottom_sheet_info() _bottom_sheet_export(server.controller) + _bottom_sheet_include_upload(server) + with layout.drawer as drawer: drawer.width = 800 with html.Div(v_if=("vtu_path != ''",)): diff --git a/src/fourc_webviewer/input_file_utils/fourc_yaml_file_visualization.py b/src/fourc_webviewer/input_file_utils/fourc_yaml_file_visualization.py index 1fb205f..5cf0fbf 100644 --- a/src/fourc_webviewer/input_file_utils/fourc_yaml_file_visualization.py +++ b/src/fourc_webviewer/input_file_utils/fourc_yaml_file_visualization.py @@ -23,7 +23,9 @@ def get_variable_names_in_funct_expression(funct_expression: str): regular expressions.""" vars_found = re.findall(r"[A-Za-z_]+", funct_expression) return [ - v for v in vars_found if v not in DEF_FUNCT and v not in ["t", "x", "y", "z"] + v + for v in vars_found + if v not in DEF_FUNCT and v not in ["t", "x", "y", "z", "e", "E"] ] diff --git a/src/fourc_webviewer/read_geometry_from_file.py b/src/fourc_webviewer/read_geometry_from_file.py index 5439671..8930da0 100644 --- a/src/fourc_webviewer/read_geometry_from_file.py +++ b/src/fourc_webviewer/read_geometry_from_file.py @@ -519,7 +519,8 @@ def __init__( if first_render: self._mesh_file = Path(fourc_yaml_file).parent / self._mesh_file else: - self._mesh_file = self._mesh_file.resolve() + self._mesh_file = (temp_dir / self._mesh_file).resolve() + if not self._mesh_file.exists(): raise Exception( f"The mesh file {self._mesh_file} does not exist for the fourc yaml file {fourc_yaml_file}" diff --git a/src/fourc_webviewer_default_files/tutorial_solid_vtu.4C.yaml b/src/fourc_webviewer_default_files/tutorial_solid_vtu.4C.yaml deleted file mode 120000 index 23d2dbf..0000000 --- a/src/fourc_webviewer_default_files/tutorial_solid_vtu.4C.yaml +++ /dev/null @@ -1 +0,0 @@ -../../tests/files/tutorial_solid_vtu.4C.yaml \ No newline at end of file diff --git a/src/fourc_webviewer_default_files/tutorial_solid_vtu.vtu b/src/fourc_webviewer_default_files/tutorial_solid_vtu.vtu deleted file mode 120000 index af21979..0000000 --- a/src/fourc_webviewer_default_files/tutorial_solid_vtu.vtu +++ /dev/null @@ -1 +0,0 @@ -../../tests/files/tutorial_solid_vtu.vtu \ No newline at end of file diff --git a/src/fourc_webviewer_default_files/tutorial_solid_vtu.vtu b/src/fourc_webviewer_default_files/tutorial_solid_vtu.vtu new file mode 100644 index 0000000..55fb465 --- /dev/null +++ b/src/fourc_webviewer_default_files/tutorial_solid_vtu.vtu @@ -0,0 +1 @@ +../../tests/files/tutorial_solid_vtu.vtu